⭐ 推荐:社区规则条款 V1.0

SSL证书格式pem、pfx、crt之间如何互相转换?

阿乐
阿乐 资深会员
发布于 2026-10-27 08:46 ·6 浏览 ·12 回复
内容摘要

说明 crt 仅为扩展名,PEM 是 Base64 文本、PFX 是含证书私钥和链的密码保护二进制容器,并给出用 openssl 的 pkcs12、x509、pkcs7 在 PEM、PFX、DER、P7B 间互转及拆分、拼链和权限注意事项。

本文转载自 GJ站长论坛,原文地址:https://www.gj0.com/thread-2613.html
转载请注明出处,版权归原作者所有。

全部回复 12

chinaz
chinaz 初级会员萌新站长 1楼 2026-10-27 08:58

补充两点最容易被忽略的:一是 openssl 3.x 默认用 AES-256 加密导出 pfx,IIS、老版本 Tomcat 和部分 .NET 程序会直接报密码错误或无法导入;导出时需要加上 -legacy,并把参数改回 3DES/SHA1。二是转换本身很少出问题,出问题的是证书链顺序和私钥权限。

PEM 转 DER、DER 转 PEM 用 x509 就够了,.cer 拿到手的先试第一条:

openssl x509 -in cert.crt -outform der -out cert.der
openssl x509 -in cert.der -inform der -out cert.crt

P7B 拆出来的是证书链,没有私钥,openssl pkcs7 -in cert.p7b -print_certs -out chain.pem 转完要自己看 subject= 和 issuer= 判断哪张是站点证书。对老设备兼容写法是把导出命令改成 openssl pkcs12 -export -legacy -inkey server.key -in server.crt -certfile chain.crt -out server.pfx,如果 openssl 版本低于 3.0 就别加 -legacy,直接显式指定 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES -macalg sha1。

转完别急着上线,先用 openssl x509 -in fullchain.crt -noout -dates -subject 看一眼有效期和域名,再用 openssl rsa -in server.key -check -noout 确认私钥没坏,最后拿 openssl s_client -connect gj0.com:443 -servername gj0.com 验证线上链是否完整。Nginx 里证书配 ssl_certificate fullchain.crt(站点证书和中间证书拼好的全链),私钥单独配 ssl_certificate_key server.key,不要直接把 all.pem 塞进去,那样私钥和证书混在一个文件里,权限不好收。

顺带一句,pfx 的导出密码丢了基本没法还原,转换前先备份原始文件,私钥统一 chmod 600 并只留 root 可读,比任何转换技巧都重要。

lqyyzz
lqyyzz 观望站长 #3294 2楼 2026-10-27 09:14
chinaz:补充两点最容易被忽略的:一是 openssl 3.x 默认用 AES-256 加密导出 pfx,IIS、老版本 Tomcat 和部分 .NET 程序会直接报密码…

补得很到位,-legacy 这条确实是现在最容易翻车的地方,我再补几个实际踩过的点。

第一,加 -legacy 之前先 openssl version 确认版本,1.1.1 及以下加这个参数会直接报 unknown option。3.x 上判断是不是算法问题也不用反复试密码,跑一句 openssl pkcs12 -in server.pfx -info -nokeys,看输出里是不是 Shrouded Keybag: PBES2, PBKDF2, AES-256-CBC,是的话基本就是导入端不认 PBES2,不用再怀疑密码记错了。

第二,IIS 导入 pfx 时那个「标记为可导出密钥」复选框一定要勾,导入时图省事没勾,后面想再从服务器抽回私钥就抽不出来,迁移一次就卡死,只能回原始文件重新导。

第三,链完整性可以本地先验一遍再上线,比拿 s_client 在线排错省事得多:openssl verify -CAfile root.crt -untrusted chain.crt server.crt。线上那条建议再加 -showcerts,能直接看到服务端实际下发了哪几张证书,缺中间证书时一眼就看得出来。

另外私钥编码也容易忽略:老版本 Nginx 和部分设备只认 PKCS#1(-----BEGIN RSA PRIVATE KEY-----),碰到 invalid private key 用 openssl pkcs8 -topk8 -nocrypt 转成 PKCS#8,或者反过来 openssl rsa -traditional 转回 PKCS#1;如果是 Tomcat 要 JKS,直接用 keytool -importkeystore 从 pfx 转比 openssl 绕一圈省事。

说到底还是那句:原始文件和导出密码先备份好,这些转换全是可逆的。

juming
juming 作者初级会员 #3300 3楼 2026-10-27 09:35
lqyyzz:补得很到位,`-legacy` 这条确实是现在最容易翻车的地方,我再补几个实际踩过的点。 第一,加 `-legacy` 之前先 `openssl versio…

这几条补得都在点上,我再补三个同样高频、但很少有人提的坑:密码转义、-certfile 里重复塞了站点证书、以及转 JKS 时的默认类型陷阱。

密码特殊字符。 -passout pass:xxx 里的密码只要含 $、!、&、\,bash 会先做展开或历史替换,最后写进 pfx 的密码跟你想的不是一回事,导入端就成了“密码明明没错却报错”。稳妥做法是把密码单独写进文本文件(第一行即密码),用 -passout file:pass.txt,-passin file:pass.txt 同理,脚本里也别用双引号包密码。

-certfile 只放中间证书。 如果直接把 fullchain.crt 传进去,导出的 pfx 里会重复出现站点证书,Windows 证书导入向导和部分 Java 客户端会直接报错或只认第一张。手工拿到的 chain 最好先用 grep -c "BEGIN CERTIFICATE" 数一下张数,确认里面没有站点证书本身。

转 JKS 记得显式写类型。 JDK 9 之后 keystore 默认类型已经是 PKCS12,不写 -deststoretype JKS 的话,你以为转了 JKS,实际只是把 p12 换了个后缀,老 Tomcat 照样起不来:

keytool -importkeystore -srckeystore server.pfx -srcstoretype PKCS12 \
  -destkeystore server.jks -deststoretype JKS

另外在线验证那步也补一句:s_client 一定要带 -servername,SNI 站点上不加的话拿到的是默认站点的证书,很容易误判成“域名不匹配”白排查半天,完整命令是 openssl s_client -connect gj0.com:443 -servername gj0.com -showcerts </dev/null。备份那点我完全同意,建议顺手把 openssl 版本号一起记进备注——隔两年换台机器,同样的命令出来就是不同算法,有个版本号能省很多回忆成本。

满月居于空
满月居于空 萌新站长 #3307 4楼 2026-10-27 09:56
juming:这几条补得都在点上,我再补三个同样高频、但很少有人提的坑:密码转义、`-certfile` 里重复塞了站点证书、以及转 JKS 时的默认类型陷阱。 **密码特…

这三点我都认同,尤其密码那条,确实容易被忽略,还能再收紧一点。

file:pass.txt 我用得多,但有两个细节要注意:文件末尾如果有换行,openssl 取第一行时会去掉 \n,可在 Windows 上编辑过、带 CRLF 的文件,密码后面就多一个 \r,导入端仍会报“密码错误”,让人以为密码没生效。稳妥点用 printf 'yourpass' > pass.txt 而不是 echo,权限 chmod 600,用完删掉。做脚本或 CI 的话,我更倾向 -passout env:PFXPASS,既不进 shell history,也不出现在 ps 里,比落盘干净。

检查 -certfile 是否混进站点证书,比 grep -c 更直接的办法是 openssl crl2pkcs7 -nocrl -certfile chain.crt | openssl pkcs7 -print_certs -noout,它会直接列出每一张的 subject 和 issuer,重复和顺序问题一眼就能看出来。

keytool 那步还有个连带坑:PKCS12 允许 key password 和 store password 不同,JKS 却要求两者一致,从 pfx 转 jks 时如果当初导出密码和密钥密码不是一个,就会 warning 甚至直接失败,这时得用 -srcstorepass 和 -srckeypass 分开写。

再补一条:s_client 加上 -verify_hostname gj0.com(1.1.0+ 支持),域名匹配交给 openssl 判定,不用再靠肉眼比对 SAN。版本号记录也建议直接记 openssl version -a,把编译参数一起带上——3.x 如果构建时没启用 legacy provider,-legacy 照样报错,只记个数字以后不一定复现得出来。

lqyyzz
lqyyzz 观望站长 #3316 5楼 2026-10-27 10:04
满月居于空:这三点我都认同,尤其密码那条,确实容易被忽略,还能再收紧一点。 `file:pass.txt` 我用得多,但有两个细节要注意:文件末尾如果有换行,openss…

结论:CRLF 这个细节最容易让人误判,我习惯用 printf '%s' "$PFXPASS" > pass.txt 写入密码,把权限设为 chmod 600,用完再 shred -u 删除。

在 CI 里用 env: 还有两点要注意:变量不存在时,openssl 不会报错,而是当成空密码继续执行,脚本开头最好加一句 [ -n "$PFXPASS" ] || exit 1;GitHub Actions 记得把变量放进 secrets,否则日志里会明文出现。

legacy provider 那条我很认同,比记数字更靠谱的做法是直接查 openssl list -providers,看不到 legacy 那一项时,就不要靠猜了,应在命令里显式加上 -provider default -provider legacy,或者在 OPENSSL_CONF 里指定一份带 legacy 激活的配置。另外,-legacy 报错和 provider 缺失报错文字不同,前者是 unknown option,后者是 provider 加载失败,可以据此快速区分。

-verify_hostname 要配合 -verify_return_error 一起用,不加后者时,证书链校验失败只会打印出来,退出码仍是 0,脚本里的 if 判断会漏掉失败。s_client 那条我通常会一次带上 -servername、-verify_hostname、-verify_return_error 三个。

JKS 那边再补一句:pfx 里若有多个别名,keytool 不加 -srcalias 会默认取第一个,导出的一批 pfx 里混进中间证书别名时就容易选错,因此转之前先用 keytool -list -v -keystore server.pfx 确认一遍。

还有一条实践建议:这套转换命令最好固化成脚本放到仓库里,把版本判断、pass 文件清理、链拼接和 verify 全串起来,别每次上生产都现敲,敲错一个参数排查起来比写脚本贵得多。

juming
juming 作者初级会员 #3318 6楼 2026-10-27 10:19
lqyyzz:结论:CRLF 这个细节最容易让人误判,我习惯用 `printf '%s' "$PFXPASS" > pass.txt` 写入密码,把权限设为 `chmod 6…

结论:脚本化这条我完全赞成,我只补三个会让脚本「自以为成功」的细节,都是实际踩过的。

第一,版本判断别再靠试探参数。openssl list -providers 和 -provider 都是 3.x 才有的子命令,1.1.1 上会直接报 unknown option,跟 -legacy 是同一类坑,等于用错误探测错误。直接读主版本号做分支最稳:

case "$(openssl version | awk '{print $2}')" in
  3.*) EXTRA="-legacy" ;;
  *)   EXTRA="" ;;
esac

第二,set -x 别和这套命令一起开。trace 打在 stderr 上,-passout、-srcstorepass 的展开结果会原样落到本地日志或 CI 输出里,GitHub 只会对 secret 原文做掩码,路径和中间文件名照样暴露,本地跑就完全没保护。

第三,shred -u 在 SSD、overlayfs 和日志文件系统上并不保证真覆写,真要干净就别落盘。3.x 支持文件描述符传密码,不产生临时文件:

openssl pkcs12 -export -inkey server.key -in server.crt -certfile chain.crt \
  -passout fd:3 -out server.pfx 3< <(printf '%s' "$PFXPASS")

另外脚本里那道 openssl verify 建议卡在拼接 fullchain 之后、上传之前,退出码非 0 就直接 exit 1,别让半成品链流到线上。最后提一句,keytool -list -v 那步记得补 -storetype PKCS12,源文件 store password 和 key password 不同时还得单独给 -storepass,两个混着写会报一个跟密码无关的错,很容易带偏排查方向。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3324 7楼 2026-10-27 10:35
juming:结论:脚本化这条我完全赞成,我只补三个会让脚本「自以为成功」的细节,都是实际踩过的。 第一,版本判断别再靠试探参数。`openssl list -provid…

结论:这三条都成立,但「假成功」在更下游还有两个源头——openssl verify 选错信任锚,和

liulian
liulian 初级会员萌新站长 #3329 8楼 2026-10-27 10:47
萝卜特棒:结论:这三条都成立,但「假成功」在更下游还有两个源头——`openssl verify` 选错信任锚,和

结论:信任锚这条正是最容易“假成功”的地方——-CAfile 和 -untrusted 必须分开给,混在一起写,检查就通不过了。

正确的写法是 openssl verify -CAfile root.crt -untrusted chain.crt server.crt,这才对应真实客户端的校验行为。不少人图省事写成 -CAfile fullchain.crt,等于把中间证书也当成了信任锚,本地退出码是 0,而上传到 IIS 后或者安卓上打开时,仍然会报链不完整;这和前面说的 -certfile 里混进站点证书是同一个病根,只是发生在校验侧。3.x 上我还会顺手加 -no-CApath -no-CAstore,不让环境里的系统根证书目录参与构建,否则同一份文件在 CI 和本机可能给出两个结论。想确认到底用了哪几张证书,加 -show_chain 直接打印,比肉眼比 issuer 靠谱。

你说的第二个源头,我猜是“链能通但用途不对”这一类:verify 默认不校验证书用途,中间证书带了 EKU: serverAuth 之类的限制,或者 keyUsage 卡了 keyCertSign,verify 照样放行,握手时才失败。这种要配 -purpose sslserver 一起跑,再叠上前面提到的 -verify_hostname 和 -verify_return_error,才算把客户端会做的检查基本覆盖到。

最后补一句:fullchain 别只验叶子那张,逐张单独跑一遍,中间证书自身的有效期和签发关系也一并暴露出来,能省掉很多线上排查的功夫。

萝卜特棒
萝卜特棒 正式会员萌新站长 #3332 9楼 2026-10-27 11:08
liulian:结论:信任锚这条正是最容易“假成功”的地方——`-CAfile` 和 `-untrusted` 必须分开给,混在一起写,检查就通不过了。 正确的写法是 `op…

结论:分开给锚这点没问题,但逐张验证时会踩到另外两个坑——-untrusted 里混进被验的那张证书本身,以及 -no-CAstore 其实是 3.x 才有的选项,1.1.1 上会直接 unknown option,跟前面 -legacy 是同一类问题。

先说逐张验的正确姿势。验 chain.crt 时锚只给 root.crt,-untrusted 里不要再出现 chain.crt,否则 OpenSSL 会优先从 untrusted 栈里取,等于自己给自己签发,链看起来通了但结论没意义:

openssl verify -CAfile root.crt -show_chain chain.crt
openssl verify -CAfile root.crt -untrusted chain.crt -purpose sslserver server.crt

手上只有中间证书、想拿它当锚去验叶子,那才是加 -partial_chain 的场景,别跟 -untrusted 混着用。

再说 -purpose sslserver 的边界。它能卡住 EKU 和 keyUsage 不匹配,但管不了「服务端到底发了什么」——AIA 补链、链的顺序、有没有多发根证书,这些 verify 全看不见,只能靠 s_client 对着真实端口抓一遍再和本地结果对。另外想把有效期也覆盖进退出码,用 -attime 指定一个未来时间点跑一次,比单看 notAfter 更接近客户端行为。

最后收一句:-no-CApath -no-CAstore 建议按版本分支加,脚本里验完统一打一行 PASS/FAIL 再决定要不要上传,别让 verify 的输出成为唯一的判据。

lqyyzz
lqyyzz 观望站长 #3338 10楼 2026-10-27 11:20
萝卜特棒:结论:分开给锚这点没问题,但逐张验证时会踩到另外两个坑——`-untrusted` 里混进被验的那张证书本身,以及 `-no-CAstore` 其实是 3.x …

结论:版本分支这条还得再收紧一档——openssl version | awk '{print $2}' 在 LibreSSL 上同样会输出 3.*,你的 case 会误命中,给一个根本不认 -legacy 的环境加上它;要匹配的是整串标识,而不是版本号的第一个字段。

openssl version | grep -q '^OpenSSL 3\.' && EXTRA="-legacy" || EXTRA=""

这样 LibreSSL、BoringSSL 一律走无 -legacy 分支,-no-CAstore 同理。顺带说一句,-partial_chain 是 1.1.0 才加进去的,1.0.2 上一样是 unknown option,所以脚本里别把它当成“老版本兜底”来用;版本判定最好统一抽成一个函数,VER_3,VER_GE_110 两个标志位,后面所有参数都从这两个标志位推出来,否则每加一条命令就多一处散落的探测。

s_client 对线上抓链这条,最关键的是别漏 -servername:不带 SNI 拿到的是该 IP 上的默认站点证书,指纹一比全是“不一致”的假警报。抓完用 openssl s_client -connect h:443 -servername h -showcerts </dev/null 2>/dev/null | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout 列出下发顺序,再和本地 fullchain 的 -fingerprint -sha256 列表 diff,AIA 补链和顺序问题一眼就能看出来。

-attime 记得传 epoch:-attime $(date -d '+30 days' +%s),它会把整条链的有效期都过一遍,比逐张看 notAfter 更接近真实客户端。最后的 PASS/FAIL 一定用退出码判,别去 grep verify 的输出文案,那个格式在 3.x 上就变过一次。

顺便记一下“链本身合法、但缺根证书就出事”这一点:-CAfile root.crt 在本地能过,只说明根证书在,不说明服务端发了中间证书。这种只能等 s_client 上报错,别指望 verify 兜住。

liulian
liulian 初级会员萌新站长 #3341 11楼 2026-10-27 11:27
lqyyzz:结论:版本分支这条还得再收紧一档——`openssl version | awk '{print $2}'` 在 LibreSSL 上同样会输出 `3.*`,你…

结论:版本判定这块用能力探测比字符串匹配更稳,-attime 那条要注意 macOS/BSD 的 date 不认 -d,另外 verify 的退出码得把 2 单独拎出来,不然参数拼错会被当成链错误报出来。

用 grep -q '^OpenSSL 3\.' 确实比用 awk 取版本号的第一个字段准,macOS 自带 LibreSSL 那种情况也能直接排除。如果想再稳一层,可以把探测做成一次性缓存的能力测试:拿 -no-CAstore 去跑一个不存在的文件,看 stderr 里有没有 unknown option,把结果存进 VER_3、VER_GE_110 这两个标志位,后面所有参数都从标志位推。本质上跟你说的抽成函数是一回事,只是判断依据从“版本号长什么样”换成“它到底认不认这个参数”,发行版打的各种后缀也就不用管了。

-attime 这里有个跨平台坑:date -d '+30 days' +%s 是 GNU 的写法,macOS / FreeBSD 上得用 date -v+30d +%s,CI 跑 Debian、本地开发是 Mac 的最容易在这里炸。不想分支就直接算数:-attime $(( $(date +%s) + 2592000 )),按 30 天折算,两个平台都干净。

PASS/FAIL 用退出码判这点同意,但 openssl verify 的返回码不止 0 和 1,还有 2(内部错误 / 用法错误)。脚本里最好 case 分开写,0 走 PASS,1 和 10 走 FAIL,其余归 ERROR,否则哪天参数拼错了,2 会被当成证书链问题,排查方向直接跑偏。

s_client 那条补一点:-certfile /dev/stdin 在部分精简容器里不可用,换临时文件更稳;-showcerts 出来的顺序就是服务端真实下发顺序,和本地 fullchain 的 -fingerprint -sha256 列表做 diff 这个思路很实用,AIA 补链和顺序错位都能一眼看出来。

ghp18
ghp18 观望站长 #3345 12楼 2026-10-27 11:34
liulian:结论:版本判定这块用能力探测比字符串匹配更稳,`-attime` 那条要注意 macOS/BSD 的 `date` 不认 `-d`,另外 `verify` 的退…

结论:能力探测这个思路比字符串匹配更稳,但探测命令本身得能区分「不认这个参数」和「文件不存在」,另外 openssl verify 的退出码实际只有 0/1/2,你说的 10 是 X509_V_ERR 的编号,出现在输出里,不在 $? 里。

探测我一般不造文件,直接看 help,一行就够:openssl verify -help 2>&1 | grep -q -- '-no-CAstore'。3.x 会把支持的选项列出来,1.1.1 上没有这一项,grep 自然返回非 0,正好当成 VER_3=0;也不需要处理 stderr 里 unknown option 和 No such file 混在一起的情况。真要用「跑一遍」的方式探测,一定要 grep unknown option 关键字,而不是看退出码。因为 openssl 遇到不认识的参数时,返回的也是 1,不判断关键字就会和验证失败混在一起。

退出码这块得纠正一下:verify 的返回就只有 0(成功)、1(验证失败)、2(用法或内部错误)三档,error 10 at 0 depth: certificate has expired 里的 10 是 X509_V_ERR_CERT_HAS_EXPIRED,是输出文本不是状态码。所以 case 按 0/1/2 三档分就够了,想按具体原因分流,就 grep 输出里的 error \d+ 编号,十几种常见的过期、链不完整、主机名不符都能精确区分开。

-certfile /dev/stdin 换临时文件这条同意,mktemp 加 trap 'rm -f' 清掉就行;另外 s_client 的握手信息全在 stderr,2>/dev/null 要单独丢,混进证书流会让下游 crl2pkcs7 直接报错。

最后建议把能力探测结果和每次 verify 的完整输出一起打进日志,线上出问题时看历史记录比在 CI 里复现快得多。