This is an automated email from the ASF dual-hosted git repository.

markt-asf pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/main by this push:
     new 224095d6f5 Review "Refactor certificate loading"
224095d6f5 is described below

commit 224095d6f52ac3afd76219d575221adad22021fe
Author: Mark Thomas <[email protected]>
AuthorDate: Tue Nov 3 14:05:04 2026 +0000

    Review "Refactor certificate loading"
    
    Skip the certificate chain file if the certificate file includes the
    chain.
---
 build.xml                                          |  29 +++-
 .../apache/tomcat/util/net/LocalStrings.properties |   1 +
 .../tomcat/util/net/SSLHostConfigCertificate.java  |   6 +-
 java/org/apache/tomcat/util/net/SSLUtilBase.java   |  17 ++-
 .../util/net/openssl/LocalStrings.properties       |   1 +
 .../tomcat/util/net/openssl/OpenSSLContext.java    |  57 +++++++-
 .../util/net/openssl/panama/CertificateLoader.java |  77 ++++++++---
 .../net/openssl/panama/LocalStrings.properties     |   1 +
 .../tomcat/util/net/TestSSLHostConfigCompat.java   | 151 +++++++++++++++++++++
 test/org/apache/tomcat/util/net/TesterSupport.java | 125 +++++++++++++++++
 .../net/openssl/panama/TestCertificateLoader.java  | 139 +++++++++++++++++++
 webapps/docs/changelog.xml                         |   9 ++
 webapps/docs/config/http.xml                       |  14 ++
 webapps/docs/ssl-howto.xml                         |   6 +
 14 files changed, 604 insertions(+), 29 deletions(-)

diff --git a/build.xml b/build.xml
index 0138bb3247..aa1b5f87ef 100644
--- a/build.xml
+++ b/build.xml
@@ -218,6 +218,11 @@
   <!-- set above.                                                            
-->
   <property name="test.jvmarg.egd" value="-Dignore" />
 
+  <!-- Flag for building FFM and associated tests -->
+  <condition property="has-ffm">
+    <javaversion atleast="${release.java.version}" />
+  </condition>
+
   <!-- Location of OpenSSL binary (file name, not directory) -->
   <!-- The OpenSSL tests cases be disabled by specifying an invalid path here 
-->
   <property name="test.openssl.path" value="" />
@@ -1053,9 +1058,6 @@
       <exclude name="org/apache/tomcat/util/openssl/**"/>
     </javac>
     <!-- Compile internal server components that use FFM -->
-    <condition property="has-ffm">
-      <javaversion atleast="${release.java.version}" />
-    </condition>
     <javac srcdir="java" destdir="${tomcat.classes}"
            debug="${compile.debug}"
            deprecation="${compile.deprecation}"
@@ -1947,6 +1949,24 @@
       <!-- Exclude these by default as Gump can't run them because the -->
       <!-- classes on which they depend are not in any output JAR.     -->
       <exclude name="org/apache/tomcat/buildutil/**" 
unless="test.includeBuildutil"/>
+      <!-- Exclude FFM tests as they are compiled below -->
+      <exclude name="org/apache/tomcat/util/net/openssl/panama/**"/>
+      <exclude name="org/apache/tomcat/util/openssl/**"/>
+    </javac>
+    <!-- Compile tests that use FFM -->
+    <javac srcdir="test" destdir="${test.classes}"
+           debug="${compile.debug}"
+           deprecation="${compile.deprecation}"
+           release="${release.java.version}"
+           encoding="ISO-8859-1"
+           includeAntRuntime="true"
+           if:set="has-ffm" >
+      <!-- Uncomment this to show unchecked warnings:
+      <compilerarg value="-Xlint:unchecked"/>
+      -->
+      <classpath refid="tomcat.test.classpath" />
+      <include name="org/apache/tomcat/util/net/openssl/panama/**"/>
+      <include name="org/apache/tomcat/util/openssl/**"/>
     </javac>
     <copy todir="${test.classes}">
       <fileset dir="test">
@@ -2291,6 +2311,9 @@
               <exclude name="**/integration/**" 
unless="test.includeIntegration" />
               <!-- Exclude tests that require large heaps -->
               <exclude name="**/*LargeHeap.java" 
unless="${test.includeLargeHeap}" />
+              <!-- Exclude FFM tests unless FFM has been compiled -->
+              <exclude name="org/apache/tomcat/util/net/openssl/panama/**" 
unless="has-ffm"/>
+              <exclude name="org/apache/tomcat/util/openssl/**" 
unless="has-ffm"/>
             </fileset>
           </batchtest>
         </junit>
diff --git a/java/org/apache/tomcat/util/net/LocalStrings.properties 
b/java/org/apache/tomcat/util/net/LocalStrings.properties
index 6bc78573bd..96e7d980e4 100644
--- a/java/org/apache/tomcat/util/net/LocalStrings.properties
+++ b/java/org/apache/tomcat/util/net/LocalStrings.properties
@@ -172,6 +172,7 @@ sslImplementation.cnfe=Unable to create SSLImplementation 
for class [{0}]
 sslUtilBase.active=The [{0}] that are active are : [{1}]
 sslUtilBase.aliasIgnored=FIPS enabled so alias name [{0}] will be ignored. If 
there is more than one key in the key store, the key used will depend on the 
key store implementation
 sslUtilBase.alias_no_key_entry=Alias name [{0}] does not identify a key entry
+sslUtilBase.chainFileIgnored=The certificate chain file [{0}] is ignored 
because the certificate file [{1}] already contains the certificate chain
 sslUtilBase.fipsPemNotSupported=FIPS enabled so PEM format certificate files 
are not supported. Configure the certificate using a key store rather than the 
certificateFile attribute
 sslUtilBase.invalidTrustManagerClassName=The trustManagerClassName provided 
[{0}] does not implement javax.net.ssl.TrustManager
 sslUtilBase.keystore_load_failed=Failed to load keystore type [{0}] with path 
[{1}] due to [{2}]
diff --git a/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java 
b/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java
index edd52cda0f..04eafc632e 100644
--- a/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java
+++ b/java/org/apache/tomcat/util/net/SSLHostConfigCertificate.java
@@ -478,7 +478,9 @@ public class SSLHostConfigCertificate implements 
Serializable {
     // OpenSSL
 
     /**
-     * Sets the path to the certificate chain file used by OpenSSL.
+     * Sets the path to the certificate chain file. The chain file is only 
used if the certificate file
+     * (a PEM file or a PKCS#12 bundle) contains the server certificate alone. 
If the certificate file already contains
+     * the certificate chain, the chain file is ignored.
      *
      * @param certificateChainFile the path to the certificate chain file
      */
@@ -489,7 +491,7 @@ public class SSLHostConfigCertificate implements 
Serializable {
 
 
     /**
-     * Returns the path to the certificate chain file used by OpenSSL.
+     * Returns the path to the certificate chain file.
      *
      * @return the certificate chain file path
      */
diff --git a/java/org/apache/tomcat/util/net/SSLUtilBase.java 
b/java/org/apache/tomcat/util/net/SSLUtilBase.java
index 68ac2ecbb8..7b1d5e5c92 100644
--- a/java/org/apache/tomcat/util/net/SSLUtilBase.java
+++ b/java/org/apache/tomcat/util/net/SSLUtilBase.java
@@ -375,12 +375,23 @@ public abstract class SSLUtilBase implements SSLUtil {
             PEMFile privateKeyFile =
                     new PEMFile(certificate.getCertificateKeyFile() != null ? 
certificate.getCertificateKeyFile() :
                             certificate.getCertificateFile(), keyPass, 
keyPassFile, null);
-            PEMFile certificateFile = new 
PEMFile(certificate.getCertificateFile());
+            // The certificate file may also hold the private key, which may 
be encrypted, so the key password is
+            // needed to read the file.
+            PEMFile certificateFile = new 
PEMFile(certificate.getCertificateFile(), keyPass, keyPassFile, null);
 
             Collection<Certificate> chain = new 
ArrayList<>(certificateFile.getCertificates());
             if (certificate.getCertificateChainFile() != null) {
-                PEMFile certificateChainFile = new 
PEMFile(certificate.getCertificateChainFile());
-                chain.addAll(certificateChainFile.getCertificates());
+                if (chain.size() > 1) {
+                    // The chain file is only used if the certificate file 
holds the server certificate alone. If the
+                    // certificate file already contains the certificate 
chain, the chain file is ignored.
+                    if (log.isDebugEnabled()) {
+                        log.debug(sm.getString("sslUtilBase.chainFileIgnored", 
certificate.getCertificateChainFile(),
+                                certificate.getCertificateFile()));
+                    }
+                } else {
+                    PEMFile certificateChainFile = new 
PEMFile(certificate.getCertificateChainFile());
+                    chain.addAll(certificateChainFile.getCertificates());
+                }
             }
 
             if (keyAlias == null) {
diff --git a/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties 
b/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties
index 2978501885..6bf1dee206 100644
--- a/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties
+++ b/java/org/apache/tomcat/util/net/openssl/LocalStrings.properties
@@ -40,6 +40,7 @@ openssl.X509FactoryError=Error getting X509 factory instance
 openssl.addedClientCaCert=Added client CA cert: [{0}]
 openssl.applyConf=Applying OpenSSLConfCmd to SSL context
 openssl.certificateVerificationFailed=Certificate verification failed
+openssl.chainFileIgnored=The certificate chain file [{0}] is ignored because 
the certificate file [{1}] already contains the certificate chain
 openssl.checkConf=Checking OpenSSLConf
 openssl.doubleInit=SSL context already initialized, ignoring
 openssl.errApplyConf=Could not apply OpenSSLConf to SSL context
diff --git a/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java 
b/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java
index 619fc78b4c..d886a650a6 100644
--- a/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java
+++ b/java/org/apache/tomcat/util/net/openssl/OpenSSLContext.java
@@ -18,6 +18,7 @@ package org.apache.tomcat.util.net.openssl;
 
 import java.io.BufferedReader;
 import java.io.FileInputStream;
+import java.io.IOException;
 import java.io.InputStreamReader;
 import java.lang.ref.Cleaner;
 import java.lang.ref.Cleaner.Cleanable;
@@ -33,6 +34,7 @@ import java.util.Arrays;
 import java.util.Base64;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.concurrent.locks.Lock;
 
@@ -550,6 +552,42 @@ public class OpenSSLContext implements 
org.apache.tomcat.util.net.SSLContext {
     }
 
 
+    /**
+     * Determines if a PEM certificate file holds more than the server 
certificate, i.e. it also holds the certificate
+     * chain. The certificates are counted without decoding the file. The file 
may also hold a private key, which may
+     * be encrypted, and the password for that key is not needed to find out 
if there is a certificate chain.
+     *
+     * @param certificateFile The certificate file
+     *
+     * @return {@code true} if the file holds the server certificate and at 
least one more certificate
+     */
+    private static boolean hasCertificateChain(String certificateFile) {
+        if (certificateFile.toLowerCase(Locale.ENGLISH).endsWith(".pkcs12")) {
+            // Not a PEM file. Leave the handling of the bundle to the native 
library.
+            return false;
+        }
+        int certificates = 0;
+        try (BufferedReader reader = new BufferedReader(
+                new InputStreamReader(new FileInputStream(certificateFile), 
StandardCharsets.ISO_8859_1))) {
+            String line;
+            while ((line = reader.readLine()) != null) {
+                // The types of PEM object that OpenSSL reads as a certificate
+                if (line.startsWith("-----BEGIN CERTIFICATE-----") ||
+                        line.startsWith("-----BEGIN X509 CERTIFICATE-----") ||
+                        line.startsWith("-----BEGIN TRUSTED 
CERTIFICATE-----")) {
+                    certificates++;
+                    if (certificates > 1) {
+                        return true;
+                    }
+                }
+            }
+        } catch (IOException e) {
+            // Leave any problems with the file to the native library.
+        }
+        return false;
+    }
+
+
     /**
      * Adds a certificate to this SSL context.
      *
@@ -571,12 +609,23 @@ public class OpenSSLContext implements 
org.apache.tomcat.util.net.SSLContext {
             } else {
                 passwordToUse = certificate.getCertificateKeyPassword();
             }
-            SSLContext.setCertificate(state.ctx, 
SSLHostConfig.adjustRelativePath(certificate.getCertificateFile()),
+            String certificateFile = 
SSLHostConfig.adjustRelativePath(certificate.getCertificateFile());
+            SSLContext.setCertificate(state.ctx, certificateFile,
                     
SSLHostConfig.adjustRelativePath(certificate.getCertificateKeyFile()), 
passwordToUse,
                     getCertificateIndex(certificate));
-            // Set certificate chain file
-            SSLContext.setCertificateChainFile(state.ctx,
-                    
SSLHostConfig.adjustRelativePath(certificate.getCertificateChainFile()), false);
+            // Set the certificate chain. The chain file is only used if the 
certificate file holds the server
+            // certificate alone. If the certificate file already contains the 
certificate chain, the chain is taken
+            // from the certificate file (skipping the server certificate) and 
the chain file is ignored.
+            if (hasCertificateChain(certificateFile)) {
+                if (log.isDebugEnabled() && 
certificate.getCertificateChainFile() != null) {
+                    log.debug(sm.getString("openssl.chainFileIgnored", 
certificate.getCertificateChainFile(),
+                            certificate.getCertificateFile()));
+                }
+                SSLContext.setCertificateChainFile(state.ctx, certificateFile, 
true);
+            } else {
+                SSLContext.setCertificateChainFile(state.ctx,
+                        
SSLHostConfig.adjustRelativePath(certificate.getCertificateChainFile()), false);
+            }
         } else {
             String alias = certificate.getCertificateKeyAlias();
             X509KeyManager x509KeyManager = 
certificate.getCertificateKeyManager();
diff --git 
a/java/org/apache/tomcat/util/net/openssl/panama/CertificateLoader.java 
b/java/org/apache/tomcat/util/net/openssl/panama/CertificateLoader.java
index 0dc416c8d1..15edc39b65 100644
--- a/java/org/apache/tomcat/util/net/openssl/panama/CertificateLoader.java
+++ b/java/org/apache/tomcat/util/net/openssl/panama/CertificateLoader.java
@@ -371,19 +371,10 @@ public final class CertificateLoader {
             return null;
         }
 
-        if (chainFile != null) {
-            byte[] chainBytes = readConfigFileBytes(chainFile);
-            if (chainBytes == null) {
-                
log.warn(sm.getString("certificateLoader.certificateLoadFailed", hostName, 
chainFile));
-            } else {
-                // Append the separately configured chain to the intermediates
-                // (if any) read from the certificate file.
-                MemorySegment[] extraChain = 
readX509ChainFromBytes(chainBytes);
-                chain = Arrays.copyOf(chain, chain.length + extraChain.length);
-                System.arraycopy(extraChain, 0, chain, chain.length - 
extraChain.length,
-                        extraChain.length);
-            }
-        }
+        // The configured chain file is only used if the certificate file holds
+        // the leaf certificate alone. If the certificate file already contains
+        // the intermediate certificates, the chain file is ignored.
+        chain = appendChainFile(certificate.getCertificateChainFile(), 
hostName, certFile, chain);
 
         return new CertificateEntry(leaf, key, chain);
     }
@@ -419,8 +410,16 @@ public final class CertificateLoader {
             KeyStore keyStore = KeyStore.getInstance("PKCS12");
             keyStore.load(new ByteArrayInputStream(fileBytes),
                     (keyPassToUse != null) ? keyPassToUse.toCharArray() : 
null);
-            return loadJavaKeyStoreKey(keyStore, 
certificate.getCertificateKeyAlias(), keyPassToUse,
-                    certFile, hostName, arena);
+            CertificateEntry entry = loadJavaKeyStoreKey(keyStore, 
certificate.getCertificateKeyAlias(),
+                    keyPassToUse, certFile, hostName, arena);
+            if (entry != null && certificate.getCertificateChainFile() != 
null) {
+                // The configured chain file is only used if the bundle holds
+                // the leaf certificate alone. If the bundle already contains
+                // the intermediate certificates, the chain file is ignored.
+                entry = new CertificateEntry(entry.leaf, entry.key, 
appendChainFile(
+                        certificate.getCertificateChainFile(), hostName, 
certFile, entry.chain));
+            }
+            return entry;
         } catch (IOException e) {
             // Covers a wrong password: the JDK fails the MAC check
             log.warn(sm.getString("certificateLoader.certificateLoadFailed", 
hostName,
@@ -606,6 +605,38 @@ public final class CertificateLoader {
 
     // ------------------------------------- File and native reading helpers
 
+    /**
+     * Applies the configured certificate chain file. The chain file is only 
used if the certificate source (a PEM
+     * certificate file or a PKCS#12 bundle) holds the leaf certificate alone. 
If the source already provides
+     * intermediate certificates, the chain file is ignored. A chain file that 
cannot be read is logged and ignored.
+     *
+     * @param chainFile  The location of the chain file, or {@code null} if 
none is configured
+     * @param hostName   The host name the certificate belongs to, used for 
logging
+     * @param source     The certificate source, used for logging
+     * @param chain      The intermediate certificates (owned by the caller) 
read from the certificate source
+     *
+     * @return the chain to use, which owns all the native objects
+     */
+    private static MemorySegment[] appendChainFile(String chainFile, String 
hostName, String source,
+            MemorySegment[] chain) {
+        if (chainFile == null) {
+            return chain;
+        }
+        if (chain.length > 0) {
+            if (log.isDebugEnabled()) {
+                log.debug(sm.getString("certificateLoader.chainFileIgnored", 
hostName, chainFile, source));
+            }
+            return chain;
+        }
+        byte[] chainBytes = readConfigFileBytes(chainFile);
+        if (chainBytes == null) {
+            log.warn(sm.getString("certificateLoader.certificateLoadFailed", 
hostName, chainFile));
+            return chain;
+        }
+        return readX509ChainFromBytes(chainBytes);
+    }
+
+
     /**
      * An OpenSSL ENGINE needs a real file. Resolves a configured location, 
relative to the Catalina base if
      * necessary, to the path of an existing file.
@@ -893,8 +924,9 @@ public final class CertificateLoader {
         CertificateFactory factory = CertificateFactory.getInstance("X.509");
         addX509Certificates(chain, factory, fileBytes);
 
+        // Only used if the certificate file holds the leaf certificate alone
         String chainFile = certificate.getCertificateChainFile();
-        if (chainFile != null) {
+        if (chainFile != null && chain.size() <= 1) {
             byte[] chainBytes = readConfigFileBytes(chainFile);
             if (chainBytes != null) {
                 addX509Certificates(chain, factory, chainBytes);
@@ -941,8 +973,19 @@ public final class CertificateLoader {
         KeyStore keyStore = KeyStore.getInstance("PKCS12");
         keyStore.load(new ByteArrayInputStream(fileBytes),
                 (password != null) ? password.toCharArray() : null);
-        return getCertificateChainKeyStore(keyStore,
+        X509Certificate[] bundleChain = getCertificateChainKeyStore(keyStore,
                 certificate.getCertificateKeyAlias());
+        // Only used if the bundle holds the leaf certificate alone
+        String chainFile = certificate.getCertificateChainFile();
+        if (bundleChain == null || bundleChain.length > 1 || chainFile == 
null) {
+            return bundleChain;
+        }
+        List<X509Certificate> chain = new 
ArrayList<>(Arrays.asList(bundleChain));
+        byte[] chainBytes = readConfigFileBytes(chainFile);
+        if (chainBytes != null) {
+            addX509Certificates(chain, 
CertificateFactory.getInstance("X.509"), chainBytes);
+        }
+        return chain.toArray(new X509Certificate[0]);
     }
 
 
diff --git 
a/java/org/apache/tomcat/util/net/openssl/panama/LocalStrings.properties 
b/java/org/apache/tomcat/util/net/openssl/panama/LocalStrings.properties
index 77a6c27697..ea2ebdab5e 100644
--- a/java/org/apache/tomcat/util/net/openssl/panama/LocalStrings.properties
+++ b/java/org/apache/tomcat/util/net/openssl/panama/LocalStrings.properties
@@ -17,6 +17,7 @@ aprListener.usingFIPSProvider=Using OpenSSL with the FIPS 
provider as the defaul
 
 certificateLoader.badPassword=Bad PKCS#12 password
 certificateLoader.certificateLoadFailed=Certificate for host [{0}] could not 
be loaded: {1}
+certificateLoader.chainFileIgnored=Certificate for host [{0}]: the certificate 
chain file [{1}] is ignored because [{2}] already contains the certificate chain
 certificateLoader.chainReadError=Error reading the certificate chain file: {0}
 certificateLoader.noKeyEntry=No usable key entry in key store
 certificateLoader.pskFindSessionError=Error in the pre-shared key find-session 
callback; the offered identity is rejected
diff --git a/test/org/apache/tomcat/util/net/TestSSLHostConfigCompat.java 
b/test/org/apache/tomcat/util/net/TestSSLHostConfigCompat.java
index c223dc3af9..f01252d52b 100644
--- a/test/org/apache/tomcat/util/net/TestSSLHostConfigCompat.java
+++ b/test/org/apache/tomcat/util/net/TestSSLHostConfigCompat.java
@@ -17,10 +17,20 @@
 package org.apache.tomcat.util.net;
 
 import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+
 import org.junit.Assert;
 import org.junit.Assume;
 import org.junit.Test;
@@ -261,6 +271,147 @@ public class TestSSLHostConfigCompat extends 
TomcatBaseTest {
     }
 
 
+    /*
+     * The certificate chain file is only used if the certificate file (a PEM 
file or a PKCS#12 bundle) holds the
+     * server certificate alone. If it already holds the certificate chain, 
the chain file is ignored.
+     *
+     * The chain file used in these tests holds the certificate of a different 
CA to the one in the certificate chain
+     * so it is possible to tell which of the two the server sends after the 
server certificate.
+     *
+     * This applies to PEM files with all of the TLS implementations. Only the 
OpenSSL FFM implementation reads PKCS#12
+     * bundles through the certificate file.
+     */
+    @Test
+    public void testHostRSAPemLeafOnlyWithChainFile() throws Exception {
+        configureHostRSAChainFileTest(new 
File(TesterSupport.LOCALHOST_RSA_CERT_PEM), false);
+        // The chain file is used
+        assertServerChain(TesterSupport.LOCALHOST_EC_CERT_PEM);
+    }
+
+
+    @Test
+    public void testHostRSAPemWithChainAndChainFile() throws Exception {
+        
configureHostRSAChainFileTest(TesterSupport.createLocalhostRsaCertAndCaPem(), 
false);
+        // The chain file is ignored
+        assertServerChain(TesterSupport.CA_CERT_PEM);
+    }
+
+
+    @Test
+    public void testHostRSAPkcs12LeafOnlyWithChainFile() throws Exception {
+        
configureHostRSAChainFileTest(TesterSupport.createLocalhostRsaPkcs12(false), 
true);
+        // The chain file is used
+        assertServerChain(TesterSupport.LOCALHOST_EC_CERT_PEM);
+    }
+
+
+    @Test
+    public void testHostRSAPkcs12WithChainAndChainFile() throws Exception {
+        
configureHostRSAChainFileTest(TesterSupport.createLocalhostRsaPkcs12(true), 
true);
+        // The chain file is ignored
+        assertServerChain(TesterSupport.CA_CERT_PEM);
+    }
+
+
+    /*
+     * The certificate file may also hold the private key and that key may be 
encrypted. That must not affect whether
+     * the certificate file is found to hold the certificate chain. The 
certificate file is the certificate, the
+     * certificate of a CA and the encrypted key.
+     */
+    @Test
+    public void testHostRSAPemWithChainAndEncryptedKeyAndChainFile() throws 
Exception {
+        doTestHostRSAPemWithChainAndEncryptedKeyAndChainFile(false);
+    }
+
+
+    @Test
+    public void 
testHostRSAPemWithChainAndEncryptedKeyAndChainFileWithPasswordFile() throws 
Exception {
+        doTestHostRSAPemWithChainAndEncryptedKeyAndChainFile(true);
+    }
+
+
+    private void doTestHostRSAPemWithChainAndEncryptedKeyAndChainFile(boolean 
passwordFile) throws Exception {
+        Assume.assumeTrue("Only needs to be run once for each implementation", 
storeType == StoreType.PEM);
+
+        SSLHostConfig sslHostConfig = getSSLHostConfig();
+        SSLHostConfigCertificate certificate = new 
SSLHostConfigCertificate(sslHostConfig, Type.RSA);
+        certificate.setCertificateFile(
+                
TesterSupport.createLocalhostRsaCertCaAndEncryptedKeyPem(TesterSupport.JKS_PASS).getAbsolutePath());
+        if (passwordFile) {
+            
certificate.setCertificateKeyPasswordFile(getPath(TesterSupport.JKS_PASS_FILE));
+        } else {
+            certificate.setCertificateKeyPassword(TesterSupport.JKS_PASS);
+        }
+        
certificate.setCertificateChainFile(getPath(TesterSupport.LOCALHOST_EC_CERT_PEM));
+        sslHostConfig.addCertificate(certificate);
+
+        getTomcatInstance().start();
+
+        // The chain file is ignored
+        assertServerChain(TesterSupport.CA_CERT_PEM);
+    }
+
+
+    private void configureHostRSAChainFileTest(File certificateFile, boolean 
pkcs12) throws Exception {
+        Assume.assumeTrue("Only needs to be run once for each implementation", 
storeType == StoreType.PEM);
+        if (pkcs12) {
+            Assume.assumeTrue("Only the OpenSSL FFM implementation reads 
PKCS#12 bundles via the certificate file",
+                    
"org.apache.tomcat.util.net.openssl.panama.OpenSSLImplementation".equals(sslImplementationName));
+        }
+
+        SSLHostConfig sslHostConfig = getSSLHostConfig();
+        SSLHostConfigCertificate certificate = new 
SSLHostConfigCertificate(sslHostConfig, Type.RSA);
+        certificate.setCertificateFile(certificateFile.getAbsolutePath());
+        if (!pkcs12) {
+            
certificate.setCertificateKeyFile(getPath(TesterSupport.LOCALHOST_RSA_KEY_PEM));
+        }
+        certificate.setCertificateKeyPassword(TesterSupport.JKS_PASS);
+        
certificate.setCertificateChainFile(getPath(TesterSupport.LOCALHOST_EC_CERT_PEM));
+        sslHostConfig.addCertificate(certificate);
+
+        getTomcatInstance().start();
+    }
+
+
+    /*
+     * Checks the server sends the server certificate followed by the 
certificate in the given file and nothing else.
+     */
+    private void assertServerChain(String expectedChainCertificate) throws 
Exception {
+        X509Certificate expected;
+        try (InputStream is = new FileInputStream(expectedChainCertificate)) {
+            expected = (X509Certificate) 
CertificateFactory.getInstance("X.509").generateCertificate(is);
+        }
+
+        // Trust everything, the test is about what the server sends
+        SSLContext clientContext = SSLContext.getInstance("TLS");
+        clientContext.init(null, new TrustManager[] { new X509TrustManager() {
+            @Override
+            public void checkClientTrusted(X509Certificate[] chain, String 
authType) {
+                // NO-OP
+            }
+
+            @Override
+            public void checkServerTrusted(X509Certificate[] chain, String 
authType) {
+                // NO-OP
+            }
+
+            @Override
+            public X509Certificate[] getAcceptedIssuers() {
+                return new X509Certificate[0];
+            }
+        } }, null);
+
+        try (SSLSocket socket = (SSLSocket) 
clientContext.getSocketFactory().createSocket("localhost", getPort())) {
+            socket.setSoTimeout(5000);
+            socket.startHandshake();
+            Certificate[] peerCertificates = 
socket.getSession().getPeerCertificates();
+
+            Assert.assertEquals(2, peerCertificates.length);
+            Assert.assertEquals(expected, peerCertificates[1]);
+        }
+    }
+
+
     private void configureHostRSA() {
         SSLHostConfig sslHostConfig = getSSLHostConfig();
         switch (storeType) {
diff --git a/test/org/apache/tomcat/util/net/TesterSupport.java 
b/test/org/apache/tomcat/util/net/TesterSupport.java
index 3592567592..674bef0e7e 100644
--- a/test/org/apache/tomcat/util/net/TesterSupport.java
+++ b/test/org/apache/tomcat/util/net/TesterSupport.java
@@ -18,11 +18,15 @@ package org.apache.tomcat.util.net;
 
 import java.io.File;
 import java.io.FileInputStream;
+import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.net.InetAddress;
 import java.net.Socket;
 import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
 import java.security.KeyStore;
 import java.security.KeyStoreException;
 import java.security.NoSuchAlgorithmException;
@@ -31,16 +35,21 @@ import java.security.PrivateKey;
 import java.security.cert.CertPathValidator;
 import java.security.cert.Certificate;
 import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
 import java.security.cert.PKIXBuilderParameters;
 import java.security.cert.PKIXRevocationChecker;
 import java.security.cert.TrustAnchor;
 import java.security.cert.X509CertSelector;
 import java.security.cert.X509Certificate;
+import java.util.Base64;
 import java.util.EnumSet;
 import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.Set;
 
+import javax.crypto.Cipher;
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
 import javax.net.ssl.CertPathTrustManagerParameters;
 import javax.net.ssl.KeyManager;
 import javax.net.ssl.KeyManagerFactory;
@@ -72,6 +81,7 @@ import org.apache.catalina.core.OpenSSLLifecycleListener;
 import org.apache.catalina.core.StandardServer;
 import org.apache.catalina.startup.TesterMapRealm;
 import org.apache.catalina.startup.Tomcat;
+import org.apache.tomcat.util.buf.Asn1Writer;
 import org.apache.tomcat.util.descriptor.web.LoginConfig;
 import org.apache.tomcat.util.descriptor.web.SecurityCollection;
 import org.apache.tomcat.util.descriptor.web.SecurityConstraint;
@@ -292,6 +302,121 @@ public final class TesterSupport {
         return ks;
     }
 
+    /**
+     * Creates a PKCS#12 bundle (the file name ends with {@code .pkcs12}) that 
holds the localhost RSA private key and
+     * its certificate. The bundle is protected with {@link #JKS_PASS}.
+     *
+     * @param includeCa If {@code true} the certificate of the CA that issued 
the certificate is included in the
+     *                      bundle so it holds the certificate chain, 
otherwise the bundle only holds the certificate
+     *
+     * @return the bundle, which is deleted when the JVM exits
+     *
+     * @throws Exception if the bundle could not be created
+     */
+    public static File createLocalhostRsaPkcs12(boolean includeCa) throws 
Exception {
+        KeyStore jks = getKeyStore(LOCALHOST_RSA_JKS);
+        String alias = null;
+        Enumeration<String> aliases = jks.aliases();
+        while (alias == null && aliases.hasMoreElements()) {
+            String candidate = aliases.nextElement();
+            if (jks.isKeyEntry(candidate)) {
+                alias = candidate;
+            }
+        }
+        Assert.assertNotNull(alias);
+        PrivateKey key = (PrivateKey) jks.getKey(alias, 
JKS_PASS.toCharArray());
+        Certificate leaf = jks.getCertificateChain(alias)[0];
+
+        Certificate[] chain;
+        if (includeCa) {
+            Certificate ca;
+            try (InputStream is = new FileInputStream(CA_CERT_PEM)) {
+                ca = 
CertificateFactory.getInstance("X.509").generateCertificate(is);
+            }
+            chain = new Certificate[] { leaf, ca };
+        } else {
+            chain = new Certificate[] { leaf };
+        }
+
+        KeyStore pkcs12 = KeyStore.getInstance("PKCS12");
+        pkcs12.load(null, null);
+        pkcs12.setKeyEntry("localhost", key, JKS_PASS.toCharArray(), chain);
+
+        File file = File.createTempFile("tomcat-test-", ".pkcs12");
+        file.deleteOnExit();
+        try (OutputStream os = new FileOutputStream(file)) {
+            pkcs12.store(os, JKS_PASS.toCharArray());
+        }
+        return file;
+    }
+
+
+    /**
+     * Creates a PEM file that holds the localhost RSA certificate followed by 
the certificate of the CA that issued
+     * it, i.e. the certificate and its certificate chain.
+     *
+     * @return the file, which is deleted when the JVM exits
+     *
+     * @throws Exception if the file could not be created
+     */
+    public static File createLocalhostRsaCertAndCaPem() throws Exception {
+        File file = File.createTempFile("tomcat-test-", ".pem");
+        file.deleteOnExit();
+        try (OutputStream os = new FileOutputStream(file)) {
+            os.write(Files.readAllBytes(new 
File(LOCALHOST_RSA_CERT_PEM).toPath()));
+            os.write('\n');
+            os.write(Files.readAllBytes(new File(CA_CERT_PEM).toPath()));
+        }
+        return file;
+    }
+
+    /**
+     * Creates a PEM file that holds the localhost RSA certificate, followed 
by the certificate of the CA that issued
+     * it (i.e. the certificate chain), followed by the localhost RSA private 
key encrypted with the given password.
+     *
+     * @param password The password to encrypt the private key with
+     *
+     * @return the file, which is deleted when the JVM exits
+     *
+     * @throws Exception if the file could not be created
+     */
+    public static File createLocalhostRsaCertCaAndEncryptedKeyPem(String 
password) throws Exception {
+        // The private key file holds an unencrypted PKCS#8 key
+        StringBuilder base64 = new StringBuilder();
+        for (String line : Files.readAllLines(new 
File(LOCALHOST_RSA_KEY_PEM).toPath())) {
+            if (!line.startsWith("-----")) {
+                base64.append(line.trim());
+            }
+        }
+        byte[] pkcs8 = Base64.getDecoder().decode(base64.toString());
+
+        String algorithm = "PBEWithHmacSHA256AndAES_128";
+        Cipher cipher = Cipher.getInstance(algorithm);
+        cipher.init(Cipher.ENCRYPT_MODE,
+                SecretKeyFactory.getInstance(algorithm).generateSecret(new 
PBEKeySpec(password.toCharArray())));
+        byte[] encrypted = cipher.doFinal(pkcs8);
+        // EncryptedPrivateKeyInfo with the PBES2 algorithm identifier 
(1.2.840.113549.1.5.13) and the parameters from
+        // the cipher. The JRE class used for this can't be used as it doesn't 
recognise the algorithm name.
+        byte[] pbes2Oid = new byte[] { 0x06, 0x09, 0x2A, (byte) 0x86, 0x48, 
(byte) 0x86, (byte) 0xF7, 0x0D, 0x01, 0x05,
+                0x0D };
+        byte[] algorithmIdentifier = Asn1Writer.writeSequence(pbes2Oid, 
cipher.getParameters().getEncoded());
+        byte[] encryptedKeyInfo =
+                Asn1Writer.writeSequence(algorithmIdentifier, 
Asn1Writer.writeOctetString(encrypted));
+
+        File file = File.createTempFile("tomcat-test-", ".pem");
+        file.deleteOnExit();
+        try (OutputStream os = new FileOutputStream(file)) {
+            os.write(Files.readAllBytes(new 
File(LOCALHOST_RSA_CERT_PEM).toPath()));
+            os.write('\n');
+            os.write(Files.readAllBytes(new File(CA_CERT_PEM).toPath()));
+            os.write('\n');
+            os.write("-----BEGIN ENCRYPTED PRIVATE 
KEY-----\n".getBytes(StandardCharsets.US_ASCII));
+            os.write(Base64.getMimeEncoder(64, new byte[] { '\n' 
}).encode(encryptedKeyInfo));
+            os.write("\n-----END ENCRYPTED PRIVATE 
KEY-----\n".getBytes(StandardCharsets.US_ASCII));
+        }
+        return file;
+    }
+
     protected static boolean isClientRenegotiationSupported(Tomcat tomcat) {
         // Disabled for Tomcat Native (part of response to CVE-2009-3555)
         // Only JRE provided JSSE implementation supports this
diff --git 
a/test/org/apache/tomcat/util/net/openssl/panama/TestCertificateLoader.java 
b/test/org/apache/tomcat/util/net/openssl/panama/TestCertificateLoader.java
new file mode 100644
index 0000000000..3b5ac8dd57
--- /dev/null
+++ b/test/org/apache/tomcat/util/net/openssl/panama/TestCertificateLoader.java
@@ -0,0 +1,139 @@
+/*
+ * 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.openssl.panama;
+
+import java.io.File;
+import java.security.cert.X509Certificate;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.tomcat.util.net.SSLHostConfig;
+import org.apache.tomcat.util.net.SSLHostConfigCertificate;
+import org.apache.tomcat.util.net.SSLHostConfigCertificate.Type;
+import org.apache.tomcat.util.net.TesterSupport;
+
+/*
+ * Only the parts of CertificateLoader that do not need the native OpenSSL 
library are tested here. Loading into
+ * native objects is covered by the tests that start a connector that uses the 
OpenSSL FFM implementation.
+ *
+ * The certificate chain file is only used if the certificate file (a PEM file 
or a PKCS#12 bundle) holds the server
+ * certificate alone. If it already holds the certificate chain, the chain 
file is ignored. The CA certificate is used
+ * for the chain file in all of these tests so it would be in the chain twice 
if the chain file was not ignored.
+ */
+public class TestCertificateLoader {
+
+    private static SSLHostConfigCertificate createCertificate(File 
certificateFile, String chainFile) {
+        SSLHostConfig sslHostConfig = new SSLHostConfig();
+        SSLHostConfigCertificate certificate = new 
SSLHostConfigCertificate(sslHostConfig, Type.RSA);
+        certificate.setCertificateFile(certificateFile.getAbsolutePath());
+        certificate.setCertificateKeyPassword(TesterSupport.JKS_PASS);
+        if (chainFile != null) {
+            certificate.setCertificateChainFile(new 
File(chainFile).getAbsolutePath());
+        }
+        return certificate;
+    }
+
+
+    private static void assertChainOfLeafAndCa(X509Certificate[] chain) {
+        Assert.assertNotNull(chain);
+        Assert.assertEquals(2, chain.length);
+        Assert.assertEquals(chain[0].getIssuerX500Principal(), 
chain[1].getSubjectX500Principal());
+    }
+
+
+    @Test
+    public void testPemLeafOnly() {
+        X509Certificate[] chain = CertificateLoader.getCertificateChain(
+                createCertificate(new 
File(TesterSupport.LOCALHOST_RSA_CERT_PEM), null));
+
+        Assert.assertNotNull(chain);
+        Assert.assertEquals(1, chain.length);
+    }
+
+
+    @Test
+    public void testPemLeafOnlyWithChainFile() {
+        X509Certificate[] chain = CertificateLoader.getCertificateChain(
+                createCertificate(new 
File(TesterSupport.LOCALHOST_RSA_CERT_PEM), TesterSupport.CA_CERT_PEM));
+
+        // The chain file is used
+        assertChainOfLeafAndCa(chain);
+    }
+
+
+    @Test
+    public void testPemWithChainAndChainFile() throws Exception {
+        X509Certificate[] chain = CertificateLoader.getCertificateChain(
+                
createCertificate(TesterSupport.createLocalhostRsaCertAndCaPem(), 
TesterSupport.CA_CERT_PEM));
+
+        // The chain file is ignored
+        assertChainOfLeafAndCa(chain);
+    }
+
+
+    @Test
+    public void testPemLeafOnlyWithMissingChainFile() {
+        X509Certificate[] chain = 
CertificateLoader.getCertificateChain(createCertificate(
+                new File(TesterSupport.LOCALHOST_RSA_CERT_PEM), 
"test/org/apache/tomcat/util/net/missing.pem"));
+
+        // A chain file that cannot be read is ignored
+        Assert.assertNotNull(chain);
+        Assert.assertEquals(1, chain.length);
+    }
+
+
+    @Test
+    public void testPkcs12LeafOnly() throws Exception {
+        X509Certificate[] chain = CertificateLoader.getCertificateChain(
+                
createCertificate(TesterSupport.createLocalhostRsaPkcs12(false), null));
+
+        Assert.assertNotNull(chain);
+        Assert.assertEquals(1, chain.length);
+    }
+
+
+    @Test
+    public void testPkcs12LeafOnlyWithChainFile() throws Exception {
+        X509Certificate[] chain = CertificateLoader.getCertificateChain(
+                
createCertificate(TesterSupport.createLocalhostRsaPkcs12(false), 
TesterSupport.CA_CERT_PEM));
+
+        // The chain file is used
+        assertChainOfLeafAndCa(chain);
+    }
+
+
+    @Test
+    public void testPkcs12WithChainAndChainFile() throws Exception {
+        X509Certificate[] chain = CertificateLoader.getCertificateChain(
+                
createCertificate(TesterSupport.createLocalhostRsaPkcs12(true), 
TesterSupport.CA_CERT_PEM));
+
+        // The chain file is ignored
+        assertChainOfLeafAndCa(chain);
+    }
+
+
+    @Test
+    public void testPkcs12LeafOnlyWithMissingChainFile() throws Exception {
+        X509Certificate[] chain = 
CertificateLoader.getCertificateChain(createCertificate(
+                TesterSupport.createLocalhostRsaPkcs12(false), 
"test/org/apache/tomcat/util/net/missing.pem"));
+
+        // A chain file that cannot be read is ignored
+        Assert.assertNotNull(chain);
+        Assert.assertEquals(1, chain.length);
+    }
+}
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 81f6f39356..7c0def85ae 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -495,6 +495,15 @@
   </subsection>
   <subsection name="Coyote">
     <changelog>
+      <fix>
+        Use the <code>certificateChainFile</code> only if the PEM certificate
+        file (all TLS implementations) or the PKCS#12 bundle (OpenSSL FFM
+        implementation) contains the server certificate alone. If it already
+        contains the certificate chain, the <code>certificateChainFile</code>
+        is ignored. The OpenSSL FFM implementation now sends the intermediate
+        certificates of a PEM certificate file or a PKCS#12 bundle to the
+        client. (markt)
+      </fix>
       <update>
         Change the default value of the <code>cookiesWithoutEquals</code>
         attribute of the <code>Rfc6265CookieProcessor</code> from
diff --git a/webapps/docs/config/http.xml b/webapps/docs/config/http.xml
index c87099b587..f040ecf048 100644
--- a/webapps/docs/config/http.xml
+++ b/webapps/docs/config/http.xml
@@ -1646,6 +1646,16 @@
       generated by <code>openssl dhparam</code> and <code>openssl 
ecparam</code>,
       respectively. The output of the respective OpenSSL command can simply
       be concatenated to the certificate file.</p>
+      <p>The file may also contain the certificate chain, that is the
+      intermediate certificates, after the server certificate. If it does, the
+      <strong>certificateChainFile</strong> is ignored.</p>
+      <p>When using the OpenSSL FFM implementation, a file with a name that 
ends
+      with <code>.pkcs12</code> is read as a PKCS#12 bundle containing the
+      server key and certificate. The password of the bundle is the
+      <strong>certificateKeyPassword</strong> or, if that is not set, the
+      <strong>certificateKeystorePassword</strong> (or the equivalent password
+      files). The bundle may also contain the certificate chain. If it does, 
the
+      <strong>certificateChainFile</strong> is ignored.</p>
       <p>This attribute is required unless
       <strong>certificateKeystoreFile</strong> is specified.</p>
     </attribute>
@@ -1657,6 +1667,10 @@
       <code>$CATALINA_BASE</code>.</p>
       <p>The certificate chain used for Tomcat should not include the server
       certificate as its first element.</p>
+      <p>The chain file is only used if the
+      <strong>certificateFile</strong> (a PEM file or a PKCS#12 bundle) 
contains
+      the server certificate alone. If the <strong>certificateFile</strong>
+      already contains the certificate chain, the chain file is ignored.</p>
       <p>Note that when using more than one certificate for different types,
       they all must use the same certificate chain.</p>
     </attribute>
diff --git a/webapps/docs/ssl-howto.xml b/webapps/docs/ssl-howto.xml
index 8eee9f1210..147e4559be 100644
--- a/webapps/docs/ssl-howto.xml
+++ b/webapps/docs/ssl-howto.xml
@@ -350,6 +350,12 @@ so it looks something like this:</p>
         />
   </SSLHostConfig>
 </Connector>]]></source>
+<p>The <code>certificateChainFile</code> is only used if the
+<code>certificateFile</code> contains the server certificate alone. If the
+<code>certificateFile</code> already contains the intermediate certificates
+after the server certificate, the <code>certificateChainFile</code> is ignored.
+The same applies when the OpenSSL FFM implementation reads the certificate from
+a PKCS#12 bundle.</p>
 
 
 <p>The configuration options and information on which attributes


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to