把一个 RSA 私钥从一个系统搬到另一个系统,最常见的失败不是权限也不是编码,而是格式不对。而这些格式看起来都长一个样:一段 -----BEGIN 开头的 Base64。
五种 PEM 头,五种不同的东西
| BEGIN 行 | 格式 | 装的是 | 带算法标识 |
|---|---|---|---|
BEGIN RSA PRIVATE KEY | PKCS#1 | RSA 私钥 | 否 |
BEGIN PRIVATE KEY | PKCS#8 | 任意算法私钥 | ✅ 是 |
BEGIN ENCRYPTED PRIVATE KEY | PKCS#8(加密) | 口令保护的私钥 | ✅ 是 |
BEGIN PUBLIC KEY | SPKI(X.509) | 任意算法公钥 | ✅ 是 |
BEGIN RSA PUBLIC KEY | PKCS#1 | RSA 公钥 | 否 |
核心区别在最后一列。
PKCS#1 是 OpenSSL 的传统格式,里面只有 RSA 的那几个大数——模数 n、公开指数 e,私钥还有 d、p、q、dp、dq、qinv。不带算法标识,因为格式本身就写死了是 RSA。
PKCS#8 和 SPKI 在外面包了一层,先声明「这是一把 rsaEncryption 算法的密钥」,再放实际数据。多这一层的意义是通用:同一个容器格式可以装 RSA、EC、Ed25519,解析方读了算法标识才知道怎么往下解。
不看 BEGIN 行怎么认
有时候你拿到的是一段裸 Base64,没有头尾行。这时候看正文里有没有 BgkqhkiG9w0BAQE。
这一段是 rsaEncryption 的算法 OID(1.2.840.113549.1.1.1)在 DER 里的编码,经 Base64 之后的片段。逻辑很直接:
- PKCS#8 和 SPKI 带算法标识 → 一定包含它
- PKCS#1 不带 → 一定不包含
这条判据和密钥长度无关,比背前缀可靠。
如果想快速目测,2048 位密钥的常见开头是:
PKCS#8 私钥 MIIEvAIBADANBgkqhkiG9w0BAQEFAASC
PKCS#1 私钥 MIIEpAIBAAKCAQEA
SPKI 公钥 MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
PKCS#1 公钥 MIIBCgKCAQEA
但前缀会随密钥长度变——4096 位的 PKCS#1 私钥开头是 MIIJKAIBAAKCAg,1024 位是 MIICWwIBAAKBgQ。所以优先用 OID 判据。
OpenSSL 3.0 改了默认格式
这一条值得单独拎出来,因为它让大量老教程失效了。
老教程说 openssl genrsa 产出 PKCS#1,这在 OpenSSL 1.x 上是对的。从 3.0 开始默认输出变成了 PKCS#8:
$ openssl version
OpenSSL 3.6.3
$ openssl genrsa -out k.pem 2048 && head -1 k.pem
-----BEGIN PRIVATE KEY----- # PKCS#8
$ openssl genrsa -traditional -out t.pem 2048 && head -1 t.pem
-----BEGIN RSA PRIVATE KEY----- # PKCS#1
要拿回 PKCS#1 得显式加 -traditional。
为什么这个变化很容易咬人:本地开发机是新版 OpenSSL、服务器上是旧版(或者反过来),同一条命令产出的格式不同,而对接方的解析代码只认其中一种。CI 里跑得好好的脚本换台机器就崩。
建议:不要假设命令的输出格式,生成完直接 head -1 看一眼。
Java 的 InvalidKeySpecException
这是这一整类问题里最高频的一个报错。
Java 标准库只支持 PKCS#8 私钥和 SPKI 公钥,不认 PKCS#1。把 BEGIN RSA PRIVATE KEY 的内容塞进 PKCS8EncodedKeySpec 必然抛 InvalidKeySpecException。这是长期存在的限制,不是 bug。
两条路:
# ① 转格式(推荐)
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
② 引入 BouncyCastle,它的 PEMParser 两种都认——但为了读一个密钥引入整个 BC 依赖,多数情况下不划算。
另外两个高频错因:
- 没剥掉 PEM 的头尾行和换行就直接 Base64 解码。
-----BEGIN.../-----END...两行和中间的所有换行都要先去掉 - 私钥是加密的(
BEGIN ENCRYPTED PRIVATE KEY),得先用口令解密。PKCS8EncodedKeySpec不会替你做这件事
各语言的支持情况
| 语言 / 库 | PKCS#1 私钥 | PKCS#8 私钥 | 备注 |
|---|---|---|---|
| Java 标准库 | ❌ | ✅ | 只有 PKCS8EncodedKeySpec |
| Node.js crypto | ✅ | ✅ | 两种都自动识别 |
| Python cryptography | ✅ | ✅ | load_pem_private_key 通吃 |
| Go 标准库 | ✅ | ✅ | 两个不同函数,用错报错 |
| PHP openssl 扩展 | ✅ | ✅ | 底层就是 OpenSSL |
| .NET | ✅ | ✅ | .NET Core 3.0 之后 |
Go 的情况值得注意:x509.ParsePKCS1PrivateKey 和 x509.ParsePKCS8PrivateKey 是两个函数,格式和函数对不上就直接报错,不会自动回退。健壮的做法是先试一个,失败再试另一个。
常用转换命令
# 私钥 PKCS#1 → PKCS#8
openssl pkcs8 -topk8 -nocrypt -in pkcs1.pem -out pkcs8.pem
# 私钥 PKCS#8 → PKCS#1(3.0 之后必须加 -traditional)
openssl rsa -in pkcs8.pem -traditional -out pkcs1.pem
# 从私钥导出公钥(SPKI 格式)
openssl rsa -in private.pem -pubout -out public.pem
# 从私钥导出公钥(PKCS#1 格式)
openssl rsa -in private.pem -RSAPublicKey_out -out pub1.pem
# 解密加密的私钥
openssl pkcs8 -in encrypted.pem -out plain.pem
# 给私钥加口令保护
openssl pkcs8 -topk8 -in plain.pem -out encrypted.pem
# PEM ↔ DER
openssl rsa -in k.pem -outform DER -out k.der
openssl rsa -in k.der -inform DER -out k.pem
# 从证书里提取公钥
openssl x509 -in cert.pem -pubkey -noout > pub.pem
# 看密钥的实际内容(位数、模数、指数)
openssl rsa -in k.pem -text -noout
最后一条在排查时很有用——能被 -text 正常解析出来,才说明密钥内容是完好的,光看 BEGIN 行对不对不够。
PEM 和 DER:同一个东西的两种写法
DER 是 ASN.1 的确定性二进制编码,直接看是一堆不可读的字节。
PEM 把 DER 做 Base64,每 64 字符换行,前后加 BEGIN / END 标记。存在的理由很实际:二进制没法贴进邮件、YAML 配置和网页表单。
后缀完全不可靠。 .pem、.key、.crt、.cer、.der 没有强制约定——.cer 里可能是 PEM 也可能是 DER,.key 里可能是 PKCS#1 也可能是 PKCS#8。
判断只看内容:
- 文本,以
-----BEGIN开头 → PEM - 二进制,第一个字节是
0x30→ DER(0x30是 ASN.1 SEQUENCE 的标签)
私钥能导出公钥,反过来不行
PKCS#1 和 PKCS#8 的私钥结构里本来就完整包含模数 n 和公开指数 e。公钥就是 (n, e) 这一对,从私钥里直接读出来即可,不需要任何计算——所以导出是瞬时的。
反过来从公钥推私钥,需要把 n 分解成两个大素数。这件事做不到,正是 RSA 成立的前提。
如果你手上只有公钥却需要解密或签名,那说明这个密钥对不是给你用的,该去找持有私钥的一方。
用工具快速识别和验证
RSA 加解密的密钥输入框对上面几种格式都做了兼容:PKCS#1、PKCS#8、SPKI、X.509 证书,甚至没有 BEGIN 行的裸 Base64 都能识别。粘贴进去就能看到解析出的密钥位数——这是判断「密钥到底完不完好」最快的办法。
工具也能直接生成密钥对,加解密和签名验签全在浏览器本地完成,私钥不会离开你的设备。
转换完成后建议做一次往返验证:用新格式的密钥加密一段短文本再解密回来。格式看着对但内容在转换中损坏的情况是存在的,只有往返能验出来。
密钥格式弄对之后,下一个常撞上的限制是明文长度,见 RSA 到底能加密多长的数据。