在Java中,使用PEM文件创建SSLContext的最简单方法是什么?

26

我使用了Let's Encrypt的CertBot免费生成PEM文件。在其他语言中,只需几行代码和PEM/密钥文件即可轻松启动HTTPS服务器。到目前为止我找到的Java解决方案过于复杂,我正在寻找更简单的东西。

  1. 我不想使用Java的命令行“keytool”。我只是想将我的PEM/密钥文件拖放到我的Eclipse中,并使用SSLContext编程启动一个HTTPS服务器。
  2. 我不想包含像BouncyCastle这样的大型外部库。请参见以下链接,了解使用BouncyCastle的所谓解决方案: 如何从PEM证书和密钥构建SSLSocketFactory而无需转换为密钥库?

是否有更好/更简单的方法来实现这一点?


3
CertBot(实际上来自EFF而不是Let's Encrypt)通常会生成多个PEM文件,您需要所有这些文件而不仅仅是一个。 Tomcat 8.5和9可以直接使用PEM文件进行配置(作为新功能),即使不使用APR,但我不知道它们如何集成到Eclipse中。 - dave_thompson_085
如果您的密钥是 -----BEGIN RSA PRIVATE KEY----- 格式,则加载它非常复杂,因为它是PKCS#1格式,而我所知道的是无法在纯Java中轻松读取。 - Robert
在Java中,通常不会以编程方式创建服务器。这就是为什么该过程不像您期望的那样直截了当的原因。相反,您可以使用常见的Web原语创建应用程序,并将其在标准服务器(如Tomcat)上部署,然后配置HTTPs。虽然也有一些工具和框架不遵循这种方式,而是自己启动一个服务器,但这已经不再是您所追求的“纯粹”的Java方式了。 - kaqqao
假设涉及到一个Web应用程序,有什么理由不能在Java服务器前面使用像nginx或apache这样的Web服务器呢?它们很擅长处理pem文件。 - vl4d1m1r4
1
我认为你已经排除了所有简单的解决方案,很抱歉。有一些简单的解决方案,比如将密钥转换为Java格式(这是你用(1)排除的),或者使用一个可以读取PEM的库(这是你用(2)排除的)。 - Rich
5个回答

4
以下代码总体上展示了如何通过解析一个包含多个条目的PEM文件(例如,几个证书和一个)为HTTPS服务器创建SSLContext。然而,它是不完整的,因为纯Java 8无法解析PKCS#1 RSA私钥数据。因此,似乎您希望在没有任何库的情况下完成它是不可能的。至少需要BouncyCastle来解析PKCS#1数据(然后可以使用BouncyCastle的PEM解析器)。
private SSLContext createSslContext() throws Exception {
    URL url = getClass().getResource("/a.pem");
    InputStream in = url.openStream();
    String pem = new String(in.readAllBytes(), StandardCharsets.UTF_8);
    Pattern parse = Pattern.compile("(?m)(?s)^---*BEGIN ([^-]+)---*$([^-]+)^---*END[^-]+-+$");
    Matcher m = parse.matcher(pem);
    CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
    Decoder decoder = Base64.getMimeDecoder();
    List<Certificate> certList = new ArrayList<>(); // java.security.cert.Certificate

    PrivateKey privateKey = null;

    int start = 0;
    while (m.find(start)) {
        String type = m.group(1);
        String base64Data = m.group(2);
        byte[] data = decoder.decode(base64Data);
        start += m.group(0).length();
        type = type.toUpperCase();
        if (type.contains("CERTIFICATE")) {
            Certificate cert = certFactory.generateCertificate(new ByteArrayInputStream(data));
            certList.add(cert);
        } else if (type.contains("RSA PRIVATE KEY")) {
            // TODO: load and parse PKCS1 data structure to get the RSA private key  
            privateKey = ...
        } else {
            System.err.println("Unsupported type: " + type);
        }

    }
    if (privateKey == null)
        throw new RuntimeException("RSA private key not found in PEM file");

    char[] keyStorePassword = new char[0];

    KeyStore keyStore = KeyStore.getInstance("JKS");
    keyStore.load(null, null);

    int count = 0;
    for (Certificate cert : certList) {
        keyStore.setCertificateEntry("cert" + count, cert);
        count++;
    }
    Certificate[] chain = certList.toArray(new Certificate[certList.size()]);
    keyStore.setKeyEntry("key", privateKey, keyStorePassword, chain);

    TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
    tmf.init(keyStore);
    KeyManagerFactory kmf = KeyManagerFactory.getInstance("RSA");
    kmf.init(keyStore, keyStorePassword);
    SSLContext sslContext = SSLContext.getInstance("TLS");
    sslContext.init(kmf.getKeyManagers(), tmf.getTrustManagers(), new SecureRandom());
    return sslContext;
}

2
CertificateFactory 已经可以读取 PEM 格式的证书(以及 DER 格式)。但是,包含该证书的 trustedCertEntry 对于 SSL/TLS 服务器来说是无用的;服务器需要在 privateKeyEntry 中同时包含证书、证书链和私钥。你链接的第二个问题涵盖了这一点,但仅适用于纯 Java,如果私钥文件是 PKCS8 格式且未加密,则可以使用,而不是 PKCS1/传统格式或加密格式,这需要使用 BC。你是说 certbot 总是生成未加密的 PKCS8 格式,或者至少在此问题的未指定情况下是这样吗? - dave_thompson_085
@dave_thompson_085 您是正确的,答案仅适用于客户端上下文,而不适用于服务器。在问题中只提到了PEM文件,但没有提到密钥文件。然而,由于提到了Let's Encrypt,看起来这是关于SSL服务器上下文的问题。 - Robert
@Robert,我更新了问题。澄清一下,这是针对服务器的。我的意图是只需拖放文件并启动服务器。我猜这可能需要多个PEM文件和密钥文件,尽管LetsEncrypt包含一个PEM,我认为它包含单个PEM中所需的所有数据,因此只需要该PEM和密钥。 - satnam
@satnam,您应该明确指定存在哪些PEM文件和密钥文件。否则很难创建一个可用的答案。 - Robert

4

虽然已经提供了一个答案,但我想提供一个需要更少代码的替代方案。请参阅下面的示例设置:

import nl.altindag.ssl.SSLFactory;
import nl.altindag.ssl.util.PemUtils;

import javax.net.ssl.SSLContext;
import javax.net.ssl.X509ExtendedKeyManager;
import javax.net.ssl.X509ExtendedTrustManager;

public class App {

    public static void main(String[] args) {
        X509ExtendedKeyManager keyManager = PemUtils.loadIdentityMaterial("certificate-chain.pem", "private-key.pem", "private-key-password".toCharArray());
        X509ExtendedTrustManager trustManager = PemUtils.loadTrustMaterial("some-trusted-certificate.pem");

        SSLFactory sslFactory = SSLFactory.builder()
                .withIdentityMaterial(keyManager)
                .withTrustMaterial(trustManager)
                .build();

        SSLContext sslContext = sslFactory.getSslContext();
    }
    
}

若要使用上述设置,您可以使用这个库:

<dependency>
    <groupId>io.github.hakky54</groupId>
    <artifactId>sslcontext-kickstart-for-pem</artifactId>
    <version>8.0.0</version>
</dependency>

上述设置需要较少的自定义代码,同时仍然实现了您想要完成的目标。您可以在此处查看库和文档:https://github.com/Hakky54/sslcontext-kickstart

非常想试试这个。可惜我的IDE(VSCode)抱怨artifactId中的连字符。我在您的GitHub上注意到您显然已经更改了名称(更改为sslcontext-kickstart),但是Maven仍然拒绝导入它。我不是任何这些Java工具的专家,请温柔点。 - Tom Stambaugh
IDE中的投诉是Syntax error on token "-", . expectedJava(1610612940)。我还看到导入行前面有一个红色波浪线,上面写着The import io cannot be resolvedJava(268435846) - Tom Stambaugh
@TomStambaugh,你可以将你的repo发布到GitHub上,这样我就可以在我的电脑上尝试一下了吗? - Hakan54
1
我找到了问题所在。代码中使用的包名与依赖项中的groupId不同。有效的导入语句是 import nl.altindag.ssl.util.PemUtils;。我的服务器现在运行得很好! - Tom Stambaugh

0

我目前使用的完整解决方案:

  1. 在您的服务器上使用certbot生成证书。我使用命令“certbot certonly -d myawesomedomain.com”
  2. 我使用以下代码将certbot证书转换为java SSLContext:https://github.com/mirraj2/bowser/blob/master/src/bowser/SSLUtils.java
package bowser;

import static com.google.common.base.Preconditions.checkState;
import static ox.util.Utils.propagate;

import java.io.File;
import java.security.KeyStore;

import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;

import com.google.common.base.Splitter;

import ox.IO;
import ox.Log;

public class SSLUtils {

  public static SSLContext createContext(String domain) {
    String pass = "spamspam";

    File dir = new File("/etc/letsencrypt/live/" + domain);
    if (!dir.exists()) {
      Log.warn("Could not find letsencrypt dir: " + dir);
      return null;
    }

    File keystoreFile = new File(dir, "keystore.jks");
    File pemFile = new File(dir, "fullchain.pem");

    boolean generateKeystore = false;

    if (keystoreFile.exists()) {
      if (keystoreFile.lastModified() < pemFile.lastModified()) {
        Log.info("SSUtils: It looks like a new PEM file was created. Regenerating the keystore.");
        keystoreFile.delete();
        generateKeystore = true;
      }
    } else {
      generateKeystore = true;
    }

    if (generateKeystore) {
      Splitter splitter = Splitter.on(' ');
      try {
        String command = "openssl pkcs12 -export -out keystore.pkcs12 -in fullchain.pem -inkey privkey.pem -passout pass:"
            + pass;
        Log.debug(command);
        Process process = new ProcessBuilder(splitter.splitToList(command))
            .directory(dir).inheritIO().start();
        checkState(process.waitFor() == 0);

        command = "keytool -importkeystore -srckeystore keystore.pkcs12 -srcstoretype PKCS12 -destkeystore keystore.jks -srcstorepass "
            + pass + " -deststorepass " + pass;
        Log.debug(command);
        process = new ProcessBuilder(splitter.splitToList(command))
            .directory(dir).inheritIO().start();
        checkState(process.waitFor() == 0);

        new File(dir, "keystore.pkcs12").delete();// cleanup
      } catch (Exception e) {
        throw propagate(e);
      }
    }

    try {
      KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType());
      keystore.load(IO.from(keystoreFile).asStream(), pass.toCharArray());

      KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
      keyManagerFactory.init(keystore, pass.toCharArray());

      SSLContext ret = SSLContext.getInstance("TLSv1.2");
      TrustManagerFactory factory = TrustManagerFactory.getInstance(
          TrustManagerFactory.getDefaultAlgorithm());
      factory.init(keystore);
      ret.init(keyManagerFactory.getKeyManagers(), factory.getTrustManagers(), null);

      return ret;
    } catch (Exception e) {
      throw propagate(e);
    }
  }

}


1
你可以直接在任何自 2005 年左右的 Java 版本中使用 PKCS12,而无需将其转换为 JKS。自 2017 年 Java 9 以来,PKCS12 是首选,JKS 已过时(但尚未实际删除)。此外,SSLv3 协议已经过时,不再被允许使用;在实践中,当您在 JSSE 上下文中请求该协议时,它会给出(不同的)可用协议,但不一定是最好的,因此最好要求使用一些好的或至少是 "Default"。 - dave_thompson_085
谢谢,这段代码是一段时间前写的,我会根据您的建议进行更新。 - satnam

0

您可以像这样执行以下操作以获取正确的SSLContext:

final String ca1 = "..load PEM file in string..";
final CertificateFactory cf = CertificateFactory.getInstance("X.509");
final Certificate cert1 = cf.generateCertificate(new ByteArrayInputStream(ca1.getBytes()));
final KeyStore keyStore = KeyStore.getInstance("JKS");
keyStore.load(null, null);
// Can add multiple truststore certificates here...
keyStore.setCertificateEntry("my-ca-1", cert1);
final SSLContext sslContext = SSLContexts.custom().setKeyStoreType(saToken)
        .loadTrustMaterial(keyStore, null)
        .build();

saToken是什么? - Talos
saToken是一种密钥库类型,如下所述:https://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#KeyStore - Vedran Vidovic
就像 David Arellano 两年前的回答一样,这只适用于客户端(_信任_服务器),对于问题所要求的服务器来说完全无用。 - dave_thompson_085

-4
  • 这只是一个临时解决方案,因为下面的代码允许您接受任何服务器,所以当您尝试这些解决方案时,应该深入检查您的代码。

  • 这段代码根本不需要任何证书。

  • 问题在于,如果情况需要,为什么您要避免这个过程?难道您不应该使用非安全服务器吗?


logger.info("Starting instance ");
        TrustManager[] tm = new TrustManager[]{new X509TrustManager() {
            public X509Certificate[] getAcceptedIssuers(){return new X509Certificate[]{};}
            public void checkClientTrusted(X509Certificate[] chain, String authType) {logger.info(" checkClientTrusted");}
            public void checkServerTrusted(X509Certificate[] chain, String authType) {logger.info(" checkServerTrusted");}

        }};

        SSLContext sslContext = SSLContext.getInstance("SSL");
        sslContext.init(null, tm , new SecureRandom());
        HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.getSocketFactory());

1
看起来那段代码是用于连接服务器的。我正在尝试启动一个服务器。 - satnam
实际上,这段代码正在创建和设置上下文,因此最后一行当然是在您想要连接时使用。相反,您可以这样做:------------------------------------------------------------------------------------------------- // 创建套接字工厂 SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();------------------------------------------------------------------------------------------------------------------------------------------------------- SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(host, port); - David Arellano
// 创建套接字工厂 SSLSocketFactory sslSocketFactory = sslContext.getSocketFactory();// 创建套接字 SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(host,port); - David Arellano
这是客户端代码。它完全依赖于信任他人的证书,而不是使用自己的证书。这并没有以任何方式回答所提出的问题。 - user207421

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