JEP 470:加密对象的 PEM 编码(预览版)

原文:JEP 470- PEM Encodings of Cryptographic Objects (Preview)
作者:
日期:2025-10-26

所有者安东尼·斯卡皮诺(Anthony Scarpino)
类型特性
范围SE
状态已关闭 / 已交付
发布版本25
组件security-libs/java.security
讨论组security-dev@openjdk.org
工作量M
持续时间M
相关内容JEP 524:加密对象的 PEM 编码(第二预览版)
审核人艾伦·贝特曼(Alan Bateman)、肖恩·马伦(Sean Mullan)
批准人肖恩·马伦(Sean Mullan)
创建时间2023 年 1 月 23 日 18:28
更新时间2025 年 8 月 28 日 06:00
问题编号8300911

摘要

引入一个 API,用于将表示加密密钥、证书和证书吊销列表的对象编码为广泛使用的 隐私增强型邮件(PEM)传输格式,并将其从该格式解码回对象。这是一个 预览 API

目标

  • 易用性 —— 定义一个简洁的 API,用于在 PEM 文本与表示密钥、证书和证书吊销列表的对象之间进行转换。
  • 支持标准 —— 支持 PEM 文本与在二进制格式 PKCS#8(用于私钥)、X.509(公钥、证书和证书吊销列表)以及 PKCS#8 v2.0(加密私钥和非对称密钥)中有标准表示的加密对象之间的转换。

动机

Java 平台 API 对诸如公钥、私钥、证书和证书吊销列表等加密对象有丰富的支持。开发人员使用这些对象来签名和验证签名、验证由 TLS 保护的网络连接以及执行其他加密操作。

应用程序经常通过用户界面、网络或存储设备来发送和接收加密对象的表示形式。由 RFC 7468 定义的 隐私增强型邮件(PEM)格式通常用于此目的。

这种文本格式最初是为通过电子邮件发送加密对象而设计的,但随着时间的推移,它已被用于其他目的并得到扩展。证书颁发机构以 PEM 格式颁发证书链。诸如 OpenSSL 之类的加密库提供用于生成和转换 PEM 编码加密对象的操作。诸如 OpenSSH 之类的安全敏感型应用程序以 PEM 格式存储通信密钥。诸如 Yubikeys 之类的硬件认证设备输入和输出 PEM 编码的加密对象。

以下是一个 PEM 编码加密对象的示例,在这种情况下是一个椭圆曲线公钥:

-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEi/kRGOL7wCPTN4KJ2ppeSt5UYB6u
cPjjuKDtFTXbguOIFDdZ65O/8HTUqS/sVzRF+dg7H3/tkQ/36KdtuADbwQ==
-----END PUBLIC KEY-----
  

一个 PEM 文本包含密钥二进制表示的 Base64 编码,其前后分别由包含“BEGIN”和“END”字样的页眉和页脚包围。页眉和页脚中的其余文本标识加密对象的类型,在这种情况下是“PUBLIC KEY”。密钥的详细信息,如其算法和内容,可以通过解析 Base64 编码的二进制表示来获取。

Java 平台不包含易于使用的用于解码和编码 PEM 格式文本的 API。这一痛点在 2022 年 4 月的 Java 加密扩展调查 中得到了验证。虽然每个加密对象都提供了一种返回其二进制编码表示的方法,并且可以使用 Base64 API 将其转换为文本,但其余的工作留给了开发人员:

  • 对公钥进行编码虽然繁琐,但相对简单。
  • 解码 PEM 编码的密钥需要仔细解析源 PEM 文本,确定用于创建密钥对象的工厂,并确定密钥的算法。
  • 加密和解密私钥需要十几行代码。

显然,我们可以做得更好。

描述

我们在 java.security 包中引入一个新接口和三个新类:

  • DEREncodable 接口由表示具有可二进制编码密钥或证书材料的加密对象的 Java 平台 API 类实现。
  • PEMEncoderPEMDecoder 类用于编码为 PEM 格式和解码 PEM 格式。这些类的实例是不可变且可重用的,即它们不会保留先前编码或解码的加密对象的信息。
  • PEMRecord 类实现了 DEREncodable,用于编码和解码表示不存在 Java 平台 API 的加密对象的 PEM 文本。

这是一个 预览 API,默认情况下处于禁用状态

要在 JDK 25 中使用此 API,必须启用预览 API:

  • 使用 javac --release 25 --enable-preview Main.java 编译程序,并使用 java --enable-preview Main 运行它;或者,
  • 使用 源代码启动器 时,使用 java --enable-preview Main.java 运行程序;或者,
  • 使用 jshell 时,使用 jshell --enable-preview 启动它。

DER 可编码的加密对象

PEM 是一种用于二进制数据的文本格式。要将加密对象编码为 PEM 文本,或将 PEM 文本解码为加密对象,我们需要一种方法在这些对象与二进制数据之间进行转换。幸运的是,用于加密密钥、证书和证书吊销列表的 Java API 都提供了将其实例转换为 可分辨编码规则(DER) 格式字节数组以及从该格式字节数组转换回来的方法。不幸的是,这些 API 并非具有层级关系,而且它们公开这些转换的方式也不统一。

因此,我们引入一个新接口 DEREncodable,用于标识提供此类转换的加密 API,其实例因此可以编码为 PEM 格式并从 PEM 格式解码。这个空接口是密封的;其允许的类和接口有 AsymmetricKeyX509CertificateX509CRLKeyPairEncryptedPrivateKeyInfoPKCS8EncodedKeySpecX509EncodedKeySpec 以及 PEMRecord

public sealed interface DEREncodable
    permits AsymmetricKey, KeyPair,
            PKCS8EncodedKeySpec, X509EncodedKeySpec,
            EncryptedPrivateKeyInfo, X509Certificate, X509CRL,
            PEMRecord
{ }
  

我们对一些允许的类和接口进行相应调整:

public non-sealed interface AsymmetricKey { ... }
public non-sealed class PKCS8EncodedKeySpec { ... }
public non-sealed class X509EncodedKeySpec { ... }
public non-sealed class EncryptedPrivateKeyInfo { ... }
public non-sealed abstract class X509Certificate { ... }
public non-sealed abstract class X509CRL { ... }
  

编码

PEMEncoder 类声明了将 DEREncodable 对象编码为 PEM 文本的方法:

public final class PEMEncoder {
    public static PEMEncoder of();
    public byte[] encode(DEREncodable so);
    public String encodeToString(DEREncodable so);
    public PEMEncoder withEncryption(char[] password);
}
  

要编码一个 DEREncodable 对象,首先通过调用 of() 获取一个 PEMEncoder 实例。返回的实例是线程安全且可重用的,所以其编码方法可以重复使用。

有两种编码方法。一种方法返回一个字节数组形式的 PEM 文本,其中的字符采用 ISO-8859-1 字符集进行编码;例如,要编码一个私钥:

PEMencoder pe = PEMEncoder.of();
byte[] pem = pe.encode(privateKey);
  

另一种编码方法将 PEM 文本作为字符串返回;例如,要将一个公钥 / 私钥对编码为字符串:

String pem = pe.encodeToString(new KeyPair(publicKey, privateKey));
  

如果你正在编码一个 PrivateKey,那么你可以通过 withEncryption 方法对其进行 加密,该方法接受一个密码并返回一个新的不可变 PEMEncoder 实例,配置为使用该密码对密钥进行加密:

String pem = pe.withEncryption(password).encodeToString(privateKey);
  

以这种方式配置的 PEMEncoder 只能编码 PrivateKey 对象。它使用默认的加密算法;要使用非默认的加密参数,或者使用不同的加密 提供程序 进行加密,需使用 EncryptedPrivateKeyInfo 对象(见 下文)。

解码

PEMDecoder 类声明了将 PEM 文本解码为 DEREncodable 对象的方法:

public final class PEMDecoder {
     public static PEMDecoder of();
     public DEREncodable decode(String str);
     public DEREncodable decode(InputStream is) throws IOException;
     public <S extends DEREncodable> S decode(String string, Class<S> cl);
     public <S extends DEREncodable> S decode(InputStream is, Class<S> cl)
         throws IOException;
     public PEMDecoder withDecryption(char[] password);
     public PEMDecoder withFactory(Provider provider);
 }
  

要解码 PEM 文本,首先通过调用 of() 获取一个 PEMDecoder 实例。返回的实例是线程安全且可重用的,所以其解码方法可以重复使用。

有四种解码方法;每种方法都返回一个 DEREncodable 对象。你可以使用 instanceof 操作符 进行模式匹配,或者使用 switch 语句 来识别返回的加密对象的类型。例如,要解码你期望编码为公钥或私钥的 PEM 文本:

PEMDecoder pd = PEMDecoder.of();
switch (pd.decode(pem)) {
    case PublicKey publicKey -> ...;
    case PrivateKey privateKey -> ...;
    default -> throw new IllegalArgumentException(...);
}
  

如果你事先知道编码的加密对象的类型,那么你可以将相应的类传递给其中一个接受 Class 参数的 decode 方法,这样就无需对方法结果的类型进行模式匹配,或者进行检查然后强制转换。例如,如果你知道类型是 ECPublicKey

ECPublicKey key = pd.decode(pem, ECPublicKey.class);
  

在这种情况下,如果类不正确,将抛出一个 ClassCastException

如果输入的 PEM 文本编码了一个私钥,那么你可以通过 withDecryption 方法对其进行解密,该方法接受一个密码并返回一个新的 PEMDecoder 实例,配置为将密钥解密为 PrivateKey 对象。以这种方式配置的 PEMDecoder 仍然可以解码未加密的对象。例如,要解密一个 ECPrivateKey

ECPrivateKey eckey = pd.withDecryption(password)
                       .decode(pem, ECPrivateKey.class);
  

如果你解码编码私钥的 PEM 文本,但没有提供密码,那么 decode 方法将返回一个 EncryptedPrivateKeyInfo 实例,可用于解密并生成一个 PrivateKey 对象(见 下文)。

在某些情况下,解码 PEM 文本时可能需要使用特定的加密提供程序。withFactory 方法返回一个新的 PEMDecoder 实例,该实例使用指定的提供程序来生成加密对象。例如,要使用特定的提供程序解码一个 Certificate

PEMDecoder d = pd.withFactory(providerFactory);
Certificate c = d.decode(pem, X509Certificate.class);
  

如果提供程序无法生成所需类型的加密对象,则会抛出一个 IllegalArgumentException

将 PEM 文本解码为加密对象时,输入字符串或字节流中 PEM 页眉之前的任何数据都将被忽略。如果你需要这些数据,可以通过 解码为 PEMRecord 对象 来获取。

如果 PEM 输入无法解析,则会抛出一个 IllegalArgumentException。从输入流读取的字节假定表示采用 ISO-8859-1 字符集编码的字符。

PEMRecord

PEMRecord 类实现了 DEREncodable 接口。其实例可以保存任何类型的 PEM 数据。因此,它使你能够编码和解码表示不存在 Java 平台 API 的加密对象的 PEM 文本,例如,PKCS#10 证书请求

public record PEMRecord(String type, String content, byte[] leadingData)
    implements DEREncodable
{
    public PEMRecord(String type, String content);
    public PEMRecord(String type, String content, byte[] leadingData);
    String type();           // 加密对象类型,取自页眉文本
                             // (例如,“PRIVATE KEY”)
    String content();            // Base64 编码的 PEM 内容
    byte[] leadingData();    // PEM 页眉之前的任何内容
}
  

当文本的 PEM 类型没有对应的 Java 平台 API 时,PEMDecoder 实例会将 PEM 文本解码为 PEMRecord 对象:

DEREncodable d = PEMDecoder.of().decode(pem);
if (d instanceof PEMRecord pr) {
    throw new IllegalArgumentException("Unhandled PEM type: " + pr.type()
                                       + "; data: " + pr.content());
}
  

如果你需要访问 PEM 文本的前置数据,或者想自己处理文本内容,在解码时可以明确要求返回 PEMRecord

PEMRecord pr = PEMDecoder.of().decode(pem, PEMRecord.class);
  

PEMEncoder 实例会将 PEMRecord 对象编码为 PEM 文本,且不会验证其内容。

EncryptedPrivateKeyInfo

现有的 EncryptedPrivateKeyInfo 类表示一个加密的私钥。为了使其能更方便地与 PEMEncoderPEMDecoder 类配合使用,我们为它添加了五个方法:

EncryptedPrivateKeyInfo {
     ...
     public static EncryptedPrivateKeyInfo
         encryptKey(PrivateKey key, char[] password);
     public static EncryptedPrivateKeyInfo
         encryptKey(PrivateKey key, char[] password,
                    String algorithm, AlgorithmParameterSpec params,
                    Provider p);
     public static EncryptedPrivateKeyInfo
         encryptKey(PrivateKey key, Key encKey, String algorithm,
                    AlgorithmParameterSpec params,
                    Provider provider, SecureRandom random);
     public PrivateKey getKey(char[] password) throws GeneralSecurityException;
     public PrivateKey getKey(Key decryptKey, Provider provider)
         throws GeneralSecurityException;
 }
  

新增的三个静态 encryptKey 方法使用给定的密码对给定的 PrivateKey 进行加密。对于高级用法,如果默认设置不够,第二个方法允许指定所有加密参数。然后,返回的 EncryptedPrivateKeyInfo 实例可以传递给 PEMEncoder 以编码为 PEM 文本:

var epki = EncryptedPrivateKeyInfo.encryptKey(privateKey, password);
byte[] pem = PEMEncoder.of().encode(epki);
  

新增的 getKey 方法对 EncryptedPrivateKeyInfo 实例中的私钥进行解密。这些方法接受一个密码,可能还需要一个加密提供程序,并返回一个 PrivateKey。当 PEMDecoder 返回一个 EncryptedPrivateKeyInfo 时,可以使用这些方法:

EncryptedPrivateKeyInfo epki = PEMDecoder.of().decode(pem);
PrivateKey key = epki.getKey(password);
  

使用 PEMEncoderEncryptedPrivateKeyInfo 加密 PrivateKey 时使用的默认基于密码的加密(PBE)算法,在 默认安全属性文件 中定义。jdk.epkcs8.defaultAlgorithm 安全属性将默认算法定义为“PBEWithHmacSHA256AndAES_128”。默认算法未来可能会改变,但这不会影响如今创建的 PEM 文本,因为该文本中编码的数据包含算法名称以及解密所需的所有其他参数。

替代方案

PEM API 是 Base64 与加密对象之间的桥梁。我们否决了许多其他潜在设计,因为它们与现有的加密 API 不太契合。虽然部分替代方案或许可行,但我们选择了当前提议的 API,因为它与 HexFormat API 以及 Base64 API 中嵌套的 EncoderDecoder 类相似。我们希望实现不可变性、线程安全性,并且在 API 中有明确区分的编码和解码路径。

我们考虑过的一些替代方案包括:

  • 扩展 EncodedKeySpec API —— 此 API 为 KeyFactory 实例及其他加密类封装了二进制编码的密钥数据。一个新的 PEMEncodedKeySpec 子类可以对封装的 PEM 文本进行类型识别,同时提供 PEM 文本与相应的私钥或公钥 EncodedKeySpec 之间的编码和解码操作。
    这种设计存在一些缺陷。首先,PEMEncodedKeySpec 类将用于转换,这并非其父类 EncodedKeySpec 的目的。其次,EncodedKeySpec 以密钥为中心,因此无法支持将证书或证书吊销列表编码为 PEM 文本。最后,一个新的 EncodedKeySpec 子类会给现有的第三方加密提供程序带来兼容性风险和易用性问题。
  • 增强 CertificateFactoryKeyFactory API —— CertificateFactory API 已经支持对 PEM 证书和证书吊销列表数据的解码,所以向 CertificateFactoryKeyFactory 添加编码方法与现有设计相符。
    由于证书有一种行业标准编码,CertificateFactory 使这种方法看起来很简单。相比之下,KeyFactory 必须支持不同的编码格式。更糟糕的是,KeyFactory 实例的提供程序不一定支持所有已知类型的非对称密钥。此外,提供程序维护者可能不愿承担 PEM 编码以及处理加密私钥的责任。这使得增强 KeyFactory 成为一个难以实现的解决方案。
  • 静态方法 —— 静态方法有利于实现不可变性和线程安全性,但加密私钥存在可用性问题。加密私钥的转换需要密码,而其他类型加密对象的转换则不需要。因此,对于静态方法,处理加密私钥时我们需要采用一些不太理想的解决方案,比如添加额外的接受加密参数的重载方法,或者强制使用 EncryptedPrivateKeyInfo 实例。让编码器和解码器存储加密密码能带来更好的用户体验。
  • 中间 PEM 对象 API —— 我们可以引入一个包装类,其实例将包含一个密钥、一份证书、一个证书吊销列表或一些 PEM 文本。这个类可以声明编码和解码方法,或者可以通过一个单独的 API 对其实例执行操作。
    虽然这种方法会提供 PEM 文本的独立表示,但灵活性过高并非好事。一个实例既能包装加密对象又能包装 PEM 文本的类可能会令人困惑,因为这本质上是两种不同的东西。从给定数据出发,设置明确区分的编码和解码路径能带来更具引导性的用户体验。
  • 单类 API —— 一个单独的 PEM 类可以同时执行编码和解码,但与静态方法和中间 PEM 对象的方法一样,它缺乏明确区分的编码和解码操作路径。将编码和解码分离到各自的类中,能使 API 更易于使用,因为每个类仅呈现所需的操作。
  • 为编码创建加密提供程序服务支持 —— 我们考虑过让加密提供程序支持在加密对象的文本表示和二进制表示之间进行转换的服务。二进制格式已经在内部被提供程序用于导入和导出加密对象,添加转换服务可能在 PEM 之外也有用处。
    然而,这种方法需要大量的基础设施,但带来的额外服务却很少。同时,它会给现有提供程序带来兼容性风险,并使 API 的使用变得复杂。
  • 引入通用的加密编码和解码 API —— 我们考虑过一个可用于多种文本格式的通用 API。但我们否决了这个方案,因为这些格式并非都具有相同的特性,在对密钥、证书链、压缩及其他选项的支持上存在差异。在一个 API 中涵盖多种格式,且部分方法特定于某种格式,这会造成混淆。

测试

测试将包括:

  • 验证所有受支持的 DEREncodable 类都能编码和解码 PEM 文本。
  • 验证 RSA、EC 和 EdDSA 加密对象可以被编码和解码。
  • 读取由第三方应用程序生成的 PEM 文本,反之亦然。
  • 使用错误的 PEM 文本进行负面测试。