在Java中同时使用自定义Truststore和默认Truststore

96

我正在用Java编写一个应用程序,通过HTTPS连接到两个Web服务器。其中一个使用默认信任链的证书,另一个使用自签名证书。当然,直接连接到第一个服务器可以正常工作,而连接到具有自签名证书的服务器直到我创建了包含该服务器证书的trustStore之后才能正常工作。然而,一旦我创建了自己的trustStore,就无法再与默认信任的服务器建立连接,因为显然默认的trustStore被忽略了。

我找到的一个解决方案是将默认trustStore中的证书添加到我的trustStore中。然而,我不喜欢这种解决方案,因为它要求我继续管理该trustStore。(我不能假设这些证书在可预见的未来保持静态,对吗?)

此外,我发现了两个类似问题的5年前的线程:

在JVM中注册多个密钥库

如何为Java服务器拥有多个SSL证书

它们都深入探讨了Java SSL基础设施。我希望现在有更方便的解决方案,我可以在我的代码安全审查中轻松解释。


哇,我不同意重复关闭,但是如果你想合并多个信任存储,你可以使用 DKS(域密钥库),它的参数对象列表中包含一个特定的示例,其中包括 cacerts。https://docs.oracle.com/javase/8/docs/api/java/security/DomainLoadStoreParameter.html - eckes
1
@eckes 让我们投票重新开放它。我已经提供了我的投票,因为原帖明确要求如何在默认的JDK受信任证书和自定义证书(自签名)之间使用,而不将其添加到默认的JDK信任存储中。所以这个问题不是重复的。 - Hakan54
@hakan54 我必须承认,我对这个程序不太熟悉(我知道我应该熟悉的:) 你会怎么做呢?是通过标记操作吗?看起来它已经重新打开了,对吧? - eckes
@eckes 我已经请求重新打开它,社区用户拥有超过3,000个声望可以投票。看起来其他人已经投票要重新打开它了。 - Hakan54
嗯,是的,奇怪的是,它似乎已经重新开放了。 - eckes
5个回答

113
你可以使用类似我在之前回答(针对不同问题)中提到的模式。
基本上,获取默认信任管理器,创建第二个信任管理器,使用自己的信任存储。将它们都包装在一个自定义信任管理器实现中,该实现将调用委托给两者(当一个失败时回退到另一个)。
TrustManagerFactory tmf = TrustManagerFactory
    .getInstance(TrustManagerFactory.getDefaultAlgorithm());
// Using null here initialises the TMF with the default trust store.
tmf.init((KeyStore) null);

// Get hold of the default trust manager
X509TrustManager defaultTm = null;
for (TrustManager tm : tmf.getTrustManagers()) {
    if (tm instanceof X509TrustManager) {
        defaultTm = (X509TrustManager) tm;
        break;
    }
}

FileInputStream myKeys = new FileInputStream("truststore.jks");

// Do the same with your trust store this time
// Adapt how you load the keystore to your needs
KeyStore myTrustStore = KeyStore.getInstance(KeyStore.getDefaultType());
myTrustStore.load(myKeys, "password".toCharArray());

myKeys.close();

tmf = TrustManagerFactory
    .getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(myTrustStore);

// Get hold of the default trust manager
X509TrustManager myTm = null;
for (TrustManager tm : tmf.getTrustManagers()) {
    if (tm instanceof X509TrustManager) {
        myTm = (X509TrustManager) tm;
        break;
    }
}

// Wrap it in your own class.
final X509TrustManager finalDefaultTm = defaultTm;
final X509TrustManager finalMyTm = myTm;
X509TrustManager customTm = new X509TrustManager() {
    @Override
    public X509Certificate[] getAcceptedIssuers() {
        // If you're planning to use client-cert auth,
        // merge results from "defaultTm" and "myTm".
        return finalDefaultTm.getAcceptedIssuers();
    }

    @Override
    public void checkServerTrusted(X509Certificate[] chain,
            String authType) throws CertificateException {
        try {
            finalMyTm.checkServerTrusted(chain, authType);
        } catch (CertificateException e) {
            // This will throw another CertificateException if this fails too.
            finalDefaultTm.checkServerTrusted(chain, authType);
        }
    }

    @Override
    public void checkClientTrusted(X509Certificate[] chain,
            String authType) throws CertificateException {
        // If you're planning to use client-cert auth,
        // do the same as checking the server.
        finalDefaultTm.checkClientTrusted(chain, authType);
    }
};


SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, new TrustManager[] { customTm }, null);

// You don't have to set this as the default context,
// it depends on the library you're using.
SSLContext.setDefault(sslContext);

您不必将该上下文设置为默认上下文。如何使用它取决于您使用的客户端库(以及它从哪里获取其套接字工厂)。

原则上,您总是需要根据需要更新信任存储。Java 7 JSSE参考指南中有一个“重要说明”,现在在同一指南的8版中已降级为{{link1:“说明”}}:

JDK在java-home/lib/security/cacerts文件中提供了有限数量的受信任根证书。如keytool参考页面所述,如果您使用此文件作为信任存储,则有责任维护(即添加和删除)此文件中包含的证书。

根据您联系的服务器的证书配置,您可能需要添加其他根证书。从相应的供应商获取所需特定的根证书。


感谢这个伟大的解决方案。这个问题困扰了我一年左右! - Jamie
这个解决方案对我非常完美。就像前面的评论一样,我花了很多时间试图弄清楚如何做到这一点,但是直到尝试你的解决方案后才取得了成功。谢谢! - djenning90
这个似乎在Java Spring容器环境中无法工作。 - Denis Wang
@DenisWang 这取决于容器中使用了什么(不确定是Spring还是Spring Boot),有可能它不使用默认的SSLContext而是使用自己的。您需要查看您正在使用的Spring部分所使用的内容。 - Bruno
1
只是提一下,sslcontext-kickstart 是一个方便的库,它提供了包装器,可以通过一个简单的构建器实现这一点,该构建器可以拥有多个信任库,包括系统信任库。https://github.com/Hakky54/sslcontext-kickstart - eckes

24
也许我回答这个问题已经晚了6年,但对其他开发者来说可能也有帮助。我也遇到了加载默认信任库和自定义信任库的同样挑战。在多个项目中使用相同的自定义解决方案后,我觉得创建一个库并公开提供给社区是很方便的。请点击这里查看:Github - SSLContext-Kickstart 用法:
import nl.altindag.ssl.SSLFactory;

import javax.net.ssl.SSLContext;
import java.security.cert.X509Certificate;
import java.nio.file.Path;
import java.util.List;

public class App {

    public static void main(String[] args) {
        Path trustStorePath = ...;
        char[] password = "password".toCharArray();

        SSLFactory sslFactory = SSLFactory.builder()
                .withDefaultTrustMaterial() // JDK trusted CA's
                .withSystemTrustMaterial()  // OS trusted CA's
                .withTrustMaterial(trustStorePath, password)
                .build();

        SSLContext sslContext = sslFactory.getSslContext();
        List<X509Certificate> trustedCertificates = sslFactory.getTrustedCertificates();
    }

}

我不太确定是否应该在这里发布这个,因为它也可以被视为推广“我的库”的方式,但我认为对于面临相同挑战的开发者来说可能会有所帮助。
你可以使用以下代码片段添加依赖项:
<dependency>
    <groupId>io.github.hakky54</groupId>
    <artifactId>sslcontext-kickstart</artifactId>
    <version>8.1.5</version>
</dependency>

10

通过调用 TrustManagerFactory.init((KeyStore)null)并获取其X509Certificates,您可以检索默认信任存储。将其与您自己的证书结合使用。您可以使用 KeyStore.load .jks .p12 文件中加载自签名证书,也可以通过CertificateFactory加载.crt(或.cer)文件。

以下是说明这一点的一些演示代码。如果您使用浏览器从stackoverflow.com下载证书,则可以运行该代码。如果注释掉加载的证书和默认证书,则代码将获得 SSLHandshakeException ,但是如果保留其中一个,它将返回状态码200。

import javax.net.ssl.*;
import java.io.*;
import java.net.URL;
import java.security.*;
import java.security.cert.*;

public class HttpsWithCustomCertificateDemo {
    public static void main(String[] args) throws Exception {
        // Create a new trust store, use getDefaultType for .jks files or "pkcs12" for .p12 files
        KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
        // Create a new trust store, use getDefaultType for .jks files or "pkcs12" for .p12 files
        trustStore.load(null, null);

        // If you comment out the following, the request will fail
        trustStore.setCertificateEntry(
                "stackoverflow",
                // To test, download the certificate from stackoverflow.com with your browser
                loadCertificate(new File("stackoverflow.crt"))
        );
        // Uncomment to following to add the installed certificates to the keystore as well
        //addDefaultRootCaCertificates(trustStore);

        SSLSocketFactory sslSocketFactory = createSslSocketFactory(trustStore);

        URL url = new URL("https://stackoverflow.com/");
        HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
        // Alternatively, to use the sslSocketFactory for all Http requests, uncomment
        //HttpsURLConnection.setDefaultSSLSocketFactory(sslSocketFactory);
        conn.setSSLSocketFactory(sslSocketFactory);
        System.out.println(conn.getResponseCode());
    }


    private static SSLSocketFactory createSslSocketFactory(KeyStore trustStore) throws GeneralSecurityException {
        TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        tmf.init(trustStore);
        TrustManager[] trustManagers = tmf.getTrustManagers();

        SSLContext sslContext = SSLContext.getInstance("SSL");
        sslContext.init(null, trustManagers, null);
        return sslContext.getSocketFactory();
    }

    private static X509Certificate loadCertificate(File certificateFile) throws IOException, CertificateException {
        try (FileInputStream inputStream = new FileInputStream(certificateFile)) {
            return (X509Certificate) CertificateFactory.getInstance("X509").generateCertificate(inputStream);
        }
    }

    private static void addDefaultRootCaCertificates(KeyStore trustStore) throws GeneralSecurityException {
        TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
        // Loads default Root CA certificates (generally, from JAVA_HOME/lib/cacerts)
        trustManagerFactory.init((KeyStore)null);
        for (TrustManager trustManager : trustManagerFactory.getTrustManagers()) {
            if (trustManager instanceof X509TrustManager) {
                for (X509Certificate acceptedIssuer : ((X509TrustManager) trustManager).getAcceptedIssuers()) {
                    trustStore.setCertificateEntry(acceptedIssuer.getSubjectDN().getName(), acceptedIssuer);
                }
            }
        }
    }
}

你是否忘记调用 load(null, null)。虽然看起来很奇怪,但它是必要的,因为它初始化了密钥库。 - Johannes Brodwall
@PedroRomão,与其试图理解问题,我用一个易于测试的代码示例替换了它。如果它对你有用,请告诉我。 - Johannes Brodwall
在添加所有证书的循环中,有一个证书会导致崩溃。我发现在使用setCertificateEntry添加我的一个证书时出现了错误--密钥库是否已初始化? - Pedro Romão
@PedroRomão 如果你将“addDefaultRootCaCertificates”这行代码保持注释状态,它还能正常工作吗? - Johannes Brodwall
是的,在那种情况下它可以工作。但我真的需要那部分代码。我不明白问题出在哪里。我不得不用 try-catch 包围那行代码。它会为 3 或 4 张证书抛出错误(不仅仅是我的,就像我之前说的那样)。 - Pedro Romão
显示剩余3条评论

6

这里是Bruno答案的更简洁版(来源)

public void configureTrustStore() throws NoSuchAlgorithmException, KeyManagementException, KeyStoreException,
        CertificateException, IOException {
    X509TrustManager jreTrustManager = getJreTrustManager();
    X509TrustManager myTrustManager = getMyTrustManager();

    X509TrustManager mergedTrustManager = createMergedTrustManager(jreTrustManager, myTrustManager);
    setSystemTrustManager(mergedTrustManager);
}

private X509TrustManager getJreTrustManager() throws NoSuchAlgorithmException, KeyStoreException {
    return findDefaultTrustManager(null);
}

private X509TrustManager getMyTrustManager() throws FileNotFoundException, KeyStoreException, IOException,
        NoSuchAlgorithmException, CertificateException {
    // Adapt to load your keystore
    try (FileInputStream myKeys = new FileInputStream("truststore.jks")) {
        KeyStore myTrustStore = KeyStore.getInstance("jks");
        myTrustStore.load(myKeys, "password".toCharArray());

        return findDefaultTrustManager(myTrustStore);
    }
}

private X509TrustManager findDefaultTrustManager(KeyStore keyStore)
        throws NoSuchAlgorithmException, KeyStoreException {
    TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    tmf.init(keyStore); // If keyStore is null, tmf will be initialized with the default trust store

    for (TrustManager tm : tmf.getTrustManagers()) {
        if (tm instanceof X509TrustManager) {
            return (X509TrustManager) tm;
        }
    }
    return null;
}

private X509TrustManager createMergedTrustManager(X509TrustManager jreTrustManager,
        X509TrustManager customTrustManager) {
    return new X509TrustManager() {
        @Override
        public X509Certificate[] getAcceptedIssuers() {
            // If you're planning to use client-cert auth,
            // merge results from "defaultTm" and "myTm".
            return jreTrustManager.getAcceptedIssuers();
        }

        @Override
        public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            try {
                customTrustManager.checkServerTrusted(chain, authType);
            } catch (CertificateException e) {
                // This will throw another CertificateException if this fails too.
                jreTrustManager.checkServerTrusted(chain, authType);
            }
        }

        @Override
        public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
            // If you're planning to use client-cert auth,
            // do the same as checking the server.
            jreTrustManager.checkClientTrusted(chain, authType);
        }

    };
}

private void setSystemTrustManager(X509TrustManager mergedTrustManager)
        throws NoSuchAlgorithmException, KeyManagementException {
    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(null, new TrustManager[] { mergedTrustManager }, null);

    // You don't have to set this as the default context,
    // it depends on the library you're using.
    SSLContext.setDefault(sslContext);
}

这帮助我解决了javax.net.ssl.SSLHandshakeException: sun.security.validator.ValidatorException: PKIX路径构建失败: sun.security.provider.certpath.SunCertPathBuilderException: 无法找到请求目标的有效认证路径。谢谢! - Kulan Sachinthana
@KulanSachinthana 太棒了,我很高兴听到这个有帮助! - Chris Bain

-2

我发现,你也可以使用Apache HttpComponents库中的SSLContextBuilder类将自定义密钥库添加到SSLContext中:

SSLContextBuilder builder = new SSLContextBuilder();
try {
     keyStore.load(null, null);
     builder.loadTrustMaterial(keyStore, null);
     builder.loadKeyMaterial(keyStore, null);
} catch (NoSuchAlgorithmException | KeyStoreException | CertificateException | IOException
          | UnrecoverableKeyException e) {
     log.error("Can not load keys from keystore '{}'", keyStore.getProvider(), e);
}
return builder.build();

1
问题不是要将所有信任材料添加到单个信任存储中吗?SSLContextBuilder#loadTrustMaterial 可用于加载一个信任存储。另一个方法 SSLContextBuilder#loadKeyMaterial 不是用于信任存储,而是用于密钥库,其目的不同。难道不是这样吗? - ramtech
1
不,我认为 loadTrustMaterial() 从给定的参数加载 TrustManagers 并将它们内部添加到列表中。因此,您可以多次调用它,TrustManagers 将被聚合。 - OliLay
2
我来看一下loadTrustMaterial会做什么。至少在Java11的实现中,列表将被替换为给定的列表。 如果你反编译sun.security.provider.JavaKeyStore#engineLoad,你会看到this.entries.clear(); - mohsen Lzd

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接