rea-reverse

X.509 证书怎么看:解析 PEM、检查证书链顺序和 openssl verify

发布者 rea-reverse更新于 约 8 分钟读完
本页目录

结论先说:看单张证书的全部内容,用 openssl x509 -in cert.pem -noout -text。检查证书链,把叶子证书和中间证书分成两个文件,执行 openssl verify -CAfile root.pem -untrusted intermediates.pem leaf.pem。别直接对 fullchain.pem 跑 openssl verify 然后相信结果:它只检查文件里的第一张证书,后面的中间证书根本不用,下文有实测。不想开终端的话,把 PEM 粘到本站的 X.509 证书解析器 里:它会解出粘贴内容里的每一张证书,检查每张证书后面跟的是不是它的颁发者,并算出指纹,全程在浏览器本地完成。

适合谁读:拿到 CA 发来的证书包、在排查 App 或服务器 TLS 报错、或者要为证书固定(pinning)取指纹的开发和运维。读完你会知道先看哪几个字段,openssl verify 实际怎么对待它的输入,以及结构化的证书链检查能说明什么、不能说明什么。

测试材料

以下操作都在 2026-10-20 完成,系统 macOS 27.0.1,OpenSSL 4.0.3(Homebrew 安装,OPENSSLDIR: "/opt/homebrew/etc/openssl@4")。用了两组证书。

第一组是我为这篇文章自建的私有链,每个字段都由自己控制:

  • Demo Root CA:EC P-256,自签名,10 年,CA:true,keyCertSign, cRLSign
  • Demo Issuing CA 1:EC P-256,5 年,CA:true, pathlen:0,由根证书签发
  • api.demo.test:RSA 2048,90 天,SAN 为 DNS:api.demo.test、DNS:*.demo.test、IP:10.0.2.2,只有 serverAuth,由中间证书签发

第二组是公开的:当天 letsencrypt.org 返回的证书链,用下面的命令抓取:

openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null > le.txt

握手用的是 TLSv1.3 和 TLS_AES_128_GCM_SHA256,最后是 Verify return code: 0 (ok),服务器一共发了四张证书:

 0 s:CN=letsencrypt.org
   i:C=US, O=Let's Encrypt, CN=YE2
 1 s:C=US, O=Let's Encrypt, CN=YE2
   i:C=US, O=ISRG, CN=Root YE
 2 s:C=US, O=ISRG, CN=Root YE
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X2
 3 s:C=US, O=Internet Security Research Group, CN=ISRG Root X2
   i:C=US, O=Internet Security Research Group, CN=ISRG Root X1

s:(主体)和 i:(颁发者)两两对照,已经能看出链的结构:每一张的颁发者就是下一张的主体。记住这一点,解析器的证书链检查测的正是它。

第一步:读一张证书

openssl x509 -in leaf.pem -noout -text

只保留最常用的字段:

        Serial Number:
            55:6d:88:07:d2:dd:64:fe:58:2e:a1:61:1b:b0:30:3e:1a:26:00:54
        Signature Algorithm: ecdsa-with-SHA256
        Issuer: CN=Demo Issuing CA 1, O=REA Samples
        Validity
            Not Before: Oct 20 09:56:11 2026 GMT
            Not After : Jan 18 09:56:11 2027 GMT
        Subject: CN=api.demo.test
            Public Key Algorithm: rsaEncryption
                Public-Key: (2048 bit)
        X509v3 extensions:
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage:
                TLS Web Server Authentication
            X509v3 Subject Alternative Name:
                DNS:api.demo.test, DNS:*.demo.test, IP Address:10.0.2.2
            X509v3 Authority Key Identifier:
                CA:30:5F:F4:F6:48:A7:74:D7:6D:A8:6D:ED:F9:B7:AC:16:34:81:BF

密钥是 RSA,签名算法却是 ECDSA,这不是写错了:签名是颁发者的私钥做的(这里是一张 EC 中间证书),公钥才是证书主体自己的。这种混合链很常见。

按最容易找到问题的顺序,依次看:

  1. Subject Alternative Name(SAN)。客户端拿主机名和 SAN 比对,不看 CN。通配符只覆盖一级:*.demo.test 能匹配 a.demo.test,匹配不了 a.b.demo.test(下文用 openssl verify 演示)。
  2. 有效期。Not After 是 GMT 时间。openssl x509 -checkend <秒数> 用退出码回答“到那时还有效吗”,适合写进监控脚本。
  3. Basic Constraints 和 Key Usage。叶子证书应该是 CA:FALSE;中间证书需要 CA:TRUE 和 keyCertSign。带 pathlen:0 的中间证书可以签叶子证书,不能再签下级 CA。
  4. Extended Key Usage。TLS 服务器要有 serverAuth。只有 clientAuth 的证书,其他字段再对,浏览器也会拒绝。
  5. Authority Key Identifier。它必须等于颁发者证书的 Subject Key Identifier,证书链那一节会用到。

只想看几个字段,OpenSSL 3 及以上可以用 -ext:

openssl x509 -in le1.pem -noout -subject -issuer -dates -ext subjectAltName,keyUsage,extendedKeyUsage,authorityInfoAccess

Let’s Encrypt 的叶子证书输出了 10 个 DNS 名称(从 cp.letsencrypt.org 到 www.letsencrypt.org),notAfter=Dec 3 14:34:31 2026 GMT(9 月 4 日签发的 90 天证书),Key Usage: critical, Digital Signature,Authority Information Access 里只有一个 CA Issuers 地址 http://ye2.i.lencr.org/,没有 OCSP 地址。如果某个客户端或监控项硬性要求 OCSP,这一点要心里有数。

指纹

openssl x509 -in leaf.pem -noout -fingerprint
openssl x509 -in leaf.pem -noout -fingerprint -sha256
SHA1 Fingerprint=9A:F2:CB:9C:75:43:C7:6E:EC:71:32:C2:EC:81:13:A0:21:51:5F:51
sha256 Fingerprint=63:FC:B4:03:BB:74:42:E2:53:49:E2:F8:48:6C:97:CD:E0:83:2E:3D:88:9D:E5:2A:AA:97:00:83:FC:E5:86:66

OpenSSL 4.0.3 的 -fingerprint 默认仍是 SHA-1,表单或证书固定配置要 SHA-256 时记得加 -sha256。这两个值是对整张 DER 证书算的摘要。Android network security config 里的证书固定值是另一回事:它是公钥(SPKI)的 SHA-256 再做 Base64,证书续期只要不换密钥,这个值就不变。解析器两种都会显示。

第二步:同样的证书放进解析器

我先把 leaf.pem + inter.pem 粘进 X.509 证书解析器,再粘整份 le.txt(PEM 块前后夹着 s_client 的各种输出也没关系)。为了给出真实数字,我还在 Node 22 下用页面同一套解析代码跑了这些文件:7,723 字节的 Let’s Encrypt 输出,5 ms 解出四张证书。

结果和 OpenSSL 一致:SHA-1、SHA-256、序列号、SAN、密钥用途、EKU、AKI/SKI 都相同。不过有三处显示上的差别,并排对照之前最好先知道。

名称顺序是反的。 OpenSSL 显示 Issuer: CN=Demo Issuing CA 1, O=REA Samples,解析器显示 O=REA Samples,CN=Demo Issuing CA 1;Let’s Encrypt 那张是 C=US, O=Let's Encrypt, CN=YE2 对 CN=YE2,O=Let's Encrypt,C=US。解析器遵循 RFC 4514,把编码里最后一个 RDN 写在最前面;OpenSSL 按编码顺序打印。名称相同,字符串不同,所以别拿文本 diff 去比。

SCT 的日志 ID 用 Base64。 OpenSSL 把证书透明度(CT)日志 ID 打成十六进制(D8:09:55:3B:…),解析器打成 Base64(2AlVO5RP…),CT 日志列表发布时用的就是 Base64。这张叶子证书有两个 SCT,时间戳都是 2026-09-04。

有效期按天显示。 页面按整天数计算剩余有效期,Let’s Encrypt 叶子证书在 2026-10-20 是 44 天。我那张 2024 年的测试证书(用 openssl req -x509 … -not_before 20240101000000Z -not_after 20250101000000Z 生成)显示为已过期;openssl x509 -checkend 0 对它输出 Certificate will expire,返回 1。

第三步:用 openssl verify 检查证书链

我在这一步先拿到了错误的结论。

openssl verify -CAfile root.pem leaf.pem
CN=api.demo.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error leaf.pem: verification failed

这在意料之中,缺了中间证书。于是我按服务器要求的顺序,把叶子证书和中间证书拼到一起:

cat leaf.pem inter.pem > fullchain.pem
openssl verify -CAfile root.pem fullchain.pem
error 20 at 0 depth lookup: unable to get local issuer certificate
error fullchain.pem: verification failed

还是同样的错误。openssl verify 对作为验证目标的文件,只读第一张证书,fullchain.pem 里的中间证书完全没被用上。中间证书要放进 -untrusted:

openssl verify -CAfile root.pem -untrusted inter.pem leaf.pem
leaf.pem: OK

(-untrusted fullchain.pem fullchain.pem 也行,叶子证书混在不受信任的证书池里没有坏处。)主机名只有在你要求时才会检查:

openssl verify -CAfile root.pem -untrusted inter.pem -verify_hostname api.demo.test leaf.pem   # OK
openssl verify -CAfile root.pem -untrusted inter.pem -verify_hostname a.b.demo.test leaf.pem
error 62 at 0 depth lookup: hostname mismatch

公开证书链这边,我把 le.txt 拆成 le1.pem(叶子证书)和 le-inter.pem(其余三张)。不加 -CAfile 时,OpenSSL 用自己的信任库:

openssl verify -show_chain -untrusted le-inter.pem le1.pem
le1.pem: OK
Chain:
depth=0: CN=letsencrypt.org (untrusted)
depth=1: C=US, O=Let's Encrypt, CN=YE2 (untrusted)
depth=2: C=US, O=ISRG, CN=Root YE (untrusted)
depth=3: C=US, O=Internet Security Research Group, CN=ISRG Root X2

注意 depth 3 没有 “(untrusted)”:OpenSSL 停在了信任库里那张自签名的 ISRG Root X2,没有用服务器发来的第四张证书(由 ISRG Root X1 交叉签名的 X2,2026-05-13 起生效)。这张交叉签名证书是给信任库里只有 X1、没有 X2 的客户端准备的。客户端最终走哪条路径取决于它自己的信任库,所以“在我电脑上验证通过”能说明的东西其实有限。

结构化的证书链检查能发现什么

解析器不验证签名,也从不访问 AIA 或 CRL 里的地址(那等于从你的浏览器去连 CA)。它检查的是结构:每相邻两张证书,第 n 张的颁发者名称是否与第 n+1 张的主体逐字节相同,颁发机构密钥标识符是否等于下一张的主体密钥标识符。我试了四组证书包:

证书包 解析器的证书链检查 openssl verify
叶子、中间 顺序正确:名称一致,密钥标识符一致 OK(加 -untrusted)
中间、叶子 顺序不对:名称和密钥标识符都不一致 不适用(顺序是服务器配置问题)
叶子、同名但换了密钥的假中间证书 顺序不对:名称一致,密钥标识符不一致 error 20
Let’s Encrypt,4 张 顺序正确(3 个链接),末尾没有自签发的根证书 OK

第三行最有参考价值。我新生成了一张同样叫 CN=Demo Issuing CA 1, O=REA Samples 的证书,换了新密钥,用同一个根签发。只看名称,它像是正确的颁发者,只有密钥标识符(和签名)能识破。一张换过密钥但名称没变的中间证书,配上旧的叶子证书,看起来就是这样。因为不验签名,“顺序正确”只代表“结构上说得通”,不代表“可信”;要做信任判断,还得跑 openssl verify。

手工用 cat 拼 fullchain.pem 时很容易拼错顺序。服务器应该先发叶子证书,再按顺序发各级中间证书(TLS 1.3 的 RFC 8446 仍要求叶子证书排第一,其余证书则要求客户端容忍乱序)。别指望每个客户端都这么宽容。

PKCS#7 证书包没有顺序

我还把证书链写进了两个 PKCS#7 文件。openssl crl2pkcs7 -nocrl -certfile fullchain.pem -outform DER 保留了叶子在前的顺序。而用 openssl cms -sign -signer leaf.pem -certfile inter.pem -stream -outform DER 生成的签名文件里,中间证书排在前面;openssl pkcs7 -print_certs 和解析器都按这个顺序列出,解析器报“顺序不对”。这没问题:PKCS#7 里的证书集合是 SET,本来就不该依赖顺序。把这种证书包装到服务器上之前,先调整顺序。

这个 -stream 生成的文件是不定长 BER 编码(以 30 80 开头)。工具页原先写着这类文件会被拒绝,现在已经能解析,我也更新了页面上的限制说明。

关于把证书粘到网站上

证书本身是公开的,但“随手把 PEM 粘进任何网站”这个习惯有风险:下一次粘的内容里可能就带着同一个文件里的私钥。我专门测了这种情况,文件里依次是 leaf.key、leaf.pem、inter.pem。解析器的代码跳过了私钥块,没有解码它(提示“不是证书的内容块(私钥、CSR)已跳过,没有读取。”),只解析了两张证书。页面在一个 Web Worker 里做这件事,它的内容安全策略是 connect-src 'none',没法把文本发到任何地方。对在线解析器来说,这是最起码的要求;更稳妥的做法是粘贴前先把私钥删掉。

REA 在这里能做什么

如果你排查的证书来自某个 App 的网络流量,下一步通常是看流量,而不是证书本身。网站接口调用追踪指南 介绍了怎么用 REA(morluto 的开源项目)抓取和读取 HAR 文件和前端 bundle;Android APK 分析指南 讲了怎么在 App 里找到证书固定的代码。只测试你自己拥有或已获授权评估的系统和应用,详见 REA 的 DISCLAIMER.md。

下一步