证书
证书链详解:叶子证书、中间证书和根证书
证书链就是把你网站的证书一路连到客户端信任的根证书的那串证书。客户端逐级检查:每张证书都必须由上一级签发,最后一张必须落在客户端自己的信任库里,整条链才算通过。线上遇到的证书链问题,绝大多数源于同一个错误:服务器只发了网站证书,漏了签发它的中间证书。
证书链里的三种证书
公网可信的 TLS 证书链里有三种角色:你部署的叶子证书、签发它的中间证书、以及客户端本来就信任的根证书。三者都是同一种 X.509 格式,区别在于私钥由谁保管、能签发什么、服务器该不该发送。
| 角色 | 私钥保管方 | 关键扩展 | 典型有效期 | 服务器要不要发 |
|---|---|---|---|---|
| 叶子证书(终端实体证书) | 你,放在 Web 服务器上 | basicConstraints: CA:FALSE,EKU 含 serverAuth |
2026-03-15 起最长 200 天,2029 年降到 47 天 | 要,排第一 |
| 中间证书(子 CA) | CA,放在在线 HSM 里 | CA:TRUE,常带 pathlen:0,keyUsage 含 keyCertSign |
几年 | 要,跟在叶子后面 |
| 根证书(信任锚) | CA,离线保存 | 自签名,CA:TRUE |
15~25 年 | 不要,随操作系统或浏览器预装 |
中间证书存在的原因是根证书太重要,不能天天拿出来用。CA/浏览器论坛的 Baseline Requirements 明确禁止用根私钥直接签发用户证书:根只签发少数几张中间证书,然后回到离线保险库。万一某张中间证书出事,CA 或浏览器可以只吊销或不信任这一张,而不必去动预装在几十亿台设备里的根证书。
客户端信任库
+-------------------------------+
| 根 CA(自签名) | <- 信任锚,服务器不发送
+---------------+---------------+
| 签发
+---------------v---------------+
| 中间 CA | <- 服务器发送(第 2 张)
+---------------+---------------+
| 签发
+---------------v---------------+
| example.com(叶子证书) | <- 服务器发送(第 1 张)
+-------------------------------+
叶子证书的最长有效期来自 CA/B 论坛 SC-081v3 投票:2026-03-15 之前 398 天,2026-03-15 起 200 天,2027-03-15 起 100 天,2029-03-15 起 47 天。续期节奏的变化见 47 天证书。
客户端如何验证每一环
对链上每一对相邻证书,客户端都做同一组检查,任何一项不通过,整条链都失败。RFC 5280 第 6 节定义了路径验证算法,浏览器和各种 TLS 库在此基础上再加各自的策略。
- 签名:用上级证书的公钥验证下级证书
tbsCertificate(证书中被签名的部分)上的签名。 - 名称衔接:下级的“颁发者”字段必须等于上级的“使用者”字段;如果有密钥标识符,下级的 AKI(颁发机构密钥标识符)应等于上级的 SKI(使用者密钥标识符)。
- 有效期:当前时间必须落在叶子和每张中间证书的
notBefore~notAfter之间。 - CA 权限:每个签发者都必须有
basicConstraints CA:TRUE,keyUsage 包含keyCertSign;pathLenConstraint限制它下面还能有几层 CA。 - 用途:叶子证书需要
serverAuth扩展密钥用法;浏览器还会把中间证书里的 EKU 当作对下级的限制。 - 名称约束和策略:如果中间证书带名称约束,叶子证书里的所有域名都必须落在约束范围内。
- 主机名:你访问的域名必须出现在叶子证书的 SAN(使用者可选名称)里,现代客户端已经不看 Common Name。
- 吊销状态:不同客户端分别用 CRL、OCSP、Chrome 的 CRLSets 或 Firefox 的 CRLite,详见 OCSP 与 CRL。
根证书可信,靠的不是它自己的自签名,而是根证书计划(Chrome、Mozilla、Apple、微软)审核了这家 CA 并把根放进信任库。所以服务器发根证书没有意义:信任它的客户端不需要你这份,不信任它的客户端也不会因为你发了就信任。
路径构建:找到链比验证链更难
服务器发来的是一组证书,但真正的验证路径由客户端自己构建,而且可能和你设想的不一样。RFC 4158 专门讲这个过程:客户端从叶子出发,寻找可能的签发者,一直走到信任锚或者无路可走。
候选签发者有四个来源:
- TLS 握手里服务器发来的证书;
- 客户端以前连接时缓存下来的中间证书;
- 客户端的信任库;
- 如果客户端支持,按叶子证书 AIA(颁发机构信息访问)扩展里的
caIssuers地址下载。
匹配依据是颁发者名称和密钥标识符,任何证书里都能看到:
openssl x509 -in leaf.pem -noout -issuer -ext authorityKeyIdentifier,authorityInfoAccess
openssl x509 -in intermediate.pem -noout -subject -ext subjectKeyIdentifier
issuer=O=Example, CN=Example Intermediate CA
X509v3 Authority Key Identifier:
90:D2:53:AB:0B:EC:3E:74:FE:C3:EE:EC:24:F5:3A:54:15:B6:99:F6
Authority Information Access:
CA Issuers - URI:http://ca.example.com/int.der
subject=O=Example, CN=Example Intermediate CA
X509v3 Subject Key Identifier:
90:D2:53:AB:0B:EC:3E:74:FE:C3:EE:EC:24:F5:3A:54:15:B6:99:F6
叶子的 AKI 等于中间证书的 SKI,叶子的颁发者等于中间证书的使用者,客户端就知道下一步该试哪张证书。
交叉签名让一张证书有多条合法路径
交叉签名让同一个 CA 拥有两个“上级”,于是同一张叶子证书可以连到两个不同的根。做法是:为同一个 CA 名称和同一把公钥,再找另一个根签一张证书。因为公钥相同,这个 CA 签出的任何证书都能用其中任意一张验证。
Let's Encrypt 现在的结构就是很好的例子。截至 2026 年 11 月,它的 Chains of Trust 页面列出正在签发的 Generation Y 中间证书:YE1、YE2(ECDSA P-384)和 YR1、YR2(RSA 2048),分别挂在两个新根 Root YE 和 Root YR 下面。这两个新根当时还没进入各家信任库,所以由现有的 ISRG 根交叉签名:
ECDSA 证书(第一行是默认链)
example.com <- YE1 <- Root YE <- ISRG Root X2 <- ISRG Root X1
example.com <- YE1 <- Root YE <- ISRG Root X2
example.com <- YE1 <- Root YE
RSA 证书
example.com <- YR1 <- Root YR <- ISRG Root X1
example.com <- YR1 <- Root YR
只信任 ISRG Root X1 的客户端要走最长的那条;信任 ISRG Root X2 的可以早一步停下;等 Root YE 进入信任库,用新信任库的客户端到 Root YE 就结束了。注意“Root YE”在这里出现了两种身份:将来信任库里的自签名根,以及今天服务器作为中间证书发出去的交叉签名证书。
路径构建出错的经典案例:2021 年 DST Root CA X3 过期
2021 年 Let's Encrypt 的根证书切换,展示了客户端构建路径方式不同会带来什么后果。Let's Encrypt 早期挂在 IdenTrust 的 DST Root CA X3 下面,这个根在 2021 年 9 月 30 日过期。为了照顾老安卓设备,Let's Encrypt 发送的默认链里带着一张“由 DST Root CA X3 交叉签名的 ISRG Root X1”。
- 优先使用受信任证书的客户端(OpenSSL 1.1.0 起默认如此)在信任库里找到自签名的 ISRG Root X1 就停下,根本不看过期的 DST 根。
- OpenSSL 1.0.2 沿着服务器发来的链一路走到已过期的 DST Root CA X3,然后报错,尽管明明存在一条有效路径。当年不少老旧 Linux 服务器、老版本 curl 调用接口失败,就是这个原因。
- 7.1.1 之前的安卓不信任 ISRG Root X1,但也不检查信任锚是否过期,于是这张交叉签名证书又帮它们多撑了三年。
这张交叉签名证书于 2024 年 9 月 30 日到期。Let's Encrypt 表示,去掉它之后 TLS 握手里发送的证书字节减少了 40% 以上,可见链上每多一张证书都有代价。
缺少中间证书:最让人困惑的证书问题
服务器只发叶子证书时,部分浏览器照常打开,其他客户端几乎全部报错,所以这是最难排查的证书链问题。只有叶子证书无法连到根,客户端要么自己想办法找到中间证书,要么直接放弃。
| 客户端 | 缺少中间证书时的表现 |
|---|---|
| 桌面版 Chrome、Edge | 通常按 AIA 地址自动下载,页面能打开 |
| Firefox | 用内置的已知中间证书列表补齐,多数情况能打开 |
curl、wget、OpenSSL s_client |
报 “unable to get local issuer certificate” |
Python(requests、urllib) |
报 CERTIFICATE_VERIFY_FAILED |
| Java | 报 “PKIX path building failed”(默认不做 AIA 下载) |
| Node.js | 报 UNABLE_TO_VERIFY_LEAF_SIGNATURE |
| Go | 报 “x509: certificate signed by unknown authority” |
| 微信小程序、手机 App、物联网设备、支付终端、第三方回调 | 通常失败 |
开发者在桌面浏览器里看一切正常,问题往往要等监控告警、接口调用失败或用户反馈才暴露。国内常见的情况是:在宝塔面板或云服务器上只粘贴了网站证书,没粘中间证书;或者从阿里云、腾讯云下载证书时选错了服务器类型,用了不带证书链的那个文件。具体修复见 缺少中间证书、curl 60 错误、Python CERTIFICATE_VERIFY_FAILED、Java PKIX path building failed 和 Node UNABLE_TO_VERIFY_LEAF_SIGNATURE。
服务器应该发送哪些证书
先发叶子证书,再按签发顺序发每一张中间证书,不发根证书。TLS 1.2(RFC 5246)要求严格按这个顺序;TLS 1.3(RFC 8446 第 4.4.2 节)只要求自己的证书排第一,并要求客户端容忍多余或乱序的证书。但保持严格顺序没有任何成本,还能避免老客户端出问题。
大多数 ACME 客户端会直接生成正确的文件。以 certbot 为例,它会生成 cert.pem(只有叶子)、chain.pem(只有中间证书)和 fullchain.pem(叶子加中间证书)。Web 服务器应指向 fullchain.pem:
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}
acme.sh 的 --fullchain-file 输出的也是同样的完整链。Apache 2.4.8 及以上把叶子和中间证书一起放进 SSLCertificateFile 指定的文件,旧的 SSLCertificateChainFile 已经废弃。HAProxy 要求把叶子、中间证书和私钥放进同一个 PEM 文件。在宝塔面板里,“证书(PEM格式)”框要粘贴网站证书加中间证书,“密钥(KEY)”框粘贴私钥。
几条规则能避开大部分坑:
- 不要写死中间证书。 Let's Encrypt 明确说过,它随时可能不经通知改用备用中间证书签发。每次续期都应部署证书附带的那份链。
- 不要在 App 或接口客户端里固定中间证书。 对中间证书做证书固定,CA 一轮换就会全部失效。
- 密钥类型要对得上。 ECDSA 叶子证书由 ECDSA 中间证书签发,RSA 由 RSA 签发;换了密钥类型却沿用旧的 chain 文件,链就验证不过。
- 清理过期证书。 证书包里留着一张过期的中间证书,可能把严格的客户端引到死路上。
用 OpenSSL 查看和验证证书链
两条命令能回答大多数证书链问题:一条看服务器实际发了什么,另一条像严格客户端那样验证。先列出握手中的全部证书:
openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null
每张证书都带 s:(使用者)和 i:(颁发者)两行。第 0 张是叶子,它的颁发者必须是第 1 张的使用者,依此类推。如果只看到第 0 张,就是缺了中间证书。
然后在本地验证文件。只给根证书作为信任锚、不给中间证书时,结果和 curl 一样失败:
openssl verify -CAfile root.pem leaf.pem
CN=example.com
error 20 at 0 depth lookup: unable to get local issuer certificate
error leaf.pem: verification failed
把中间证书作为“不受信任”的证书提供给 OpenSSL,它就能构建完整路径,-show_chain 会把路径打印出来:
openssl verify -show_chain -CAfile root.pem -untrusted intermediate.pem leaf.pem
leaf.pem: OK
Chain:
depth=0: CN=example.com (untrusted)
depth=1: O=Example, CN=Example Intermediate CA (untrusted)
depth=2: O=Example Root, CN=Example Root CA
这里的 untrusted 只表示它不是信任锚,它照样会被检查。更多用法,包括用系统信任库验证、检查远程主机,见 证书链验证命令手册。在浏览器里也可以用 SSL 检测 和 证书链补全工具,后者能从一张叶子证书重建正确的证书链。
证书链、文件格式与体积
证书链就是几张证书排在一起,文件格式只决定它们怎么存:PEM 把几段文本直接拼接;PKCS#7(.p7b)把证书装进一个不含私钥的容器;PKCS#12(.pfx)把证书链和私钥一起加密打包。什么场景用哪种格式,见 证书格式详解。
证书链的长度也影响性能。每张证书大约给握手增加一到两 KB,这些字节要在任何网页数据之前传完,对移动网络和 QUIC 都有影响:QUIC 在验证客户端地址之前,服务器能发送的数据量是受限的。这也是 CA 尽量保持证书链简短的原因,像 Let's Encrypt 在 Generation Y 过渡期使用的那种长交叉签名链,本来就是临时安排。
常见问题
服务器要不要把根证书也一起发出去?
不要。根证书之所以可信,是因为它已经预装在客户端的信任库里,你再发一份只会多占握手字节,不会改变验证结果。唯一的例外是“被交叉签名的新根”,比如 Let's Encrypt 由 ISRG Root X2 签发的 Root YE 证书,它在这条路径里扮演的是中间证书,必须发送。
fullchain.pem 里证书的顺序应该怎么排?
叶子证书在最前面,接着是签发叶子证书的中间证书,再往上是签发这张中间证书的证书,依此类推,到根证书之前为止。TLS 1.3 只严格要求叶子证书排第一,但很多 TLS 1.2 客户端和老版本库仍按完整顺序处理。
为什么网站在 Chrome 里正常,curl 却报 unable to get local issuer certificate?
基本可以断定服务器只发了叶子证书。桌面版 Chrome 会按证书里的 AIA 地址自己把中间证书下载下来,把问题掩盖掉;curl 和 OpenSSL 从来不这样做。把完整证书链(叶子加中间证书)装好,再用 openssl s_client -showcerts 复查。
宝塔面板里“证书(PEM格式)”那一栏该填什么?
填完整证书链:先粘贴网站证书,紧接着粘贴中间证书(CA 下载包里通常叫 chain、ca-bundle 或 intermediate),不要粘根证书。只粘网站证书时,浏览器可能正常,但小程序、App 和 API 客户端会报证书错误。