三件事:证书里装了什么;根 → 中间 → 叶子怎么签出来;客户端怎么验证。本文只讨论公开信任的 TLS 服务器证书。


验证服务端证书,最终是在验证 “身份” —— 确认你正在通信的对象,就是你以为的那个对象。

一、证书是什么

一张 X.509 v3 证书就是三段:

Certificate = {
  tbsCertificate,       // 证书正文(待签名内容)
  signatureAlgorithm,   // 签名算法
  signatureValue        // 上级 CA 用其私钥对 DER(tbsCertificate) 的签名
}

正文 tbsCertificate 里 HTTPS 真正关心的字段:

字段 含义
issuer 签发者 DN(指向上级证书的 subject
subject 本证书主体 DN
validity notBefore / notAfter
subjectPublicKeyInfo 本证书绑定的公钥
subjectAltName (SAN) 覆盖的域名 / IP,唯一的身份匹配依据
basicConstraints CA:TRUE/FALSE,能否再签下级
keyUsage / extendedKeyUsage 公钥用途;TLS 服务端必须有 serverAuth
authorityKeyIdentifier 上级公钥的标识符;公开 TLS 证书里通常要求它与上级证书的 subjectKeyIdentifier 对应,用来快速找到上级证书
嵌入的 SCT CT 日志的签名时间戳;公开 TLS 证书通常用它满足浏览器 Certificate Transparency 政策

关键直觉:签发者真正签的是 DER 编码后的 tbsCertificate 字节。改一位,验签就失败。

附带概念:

  • DER:证书的二进制 ASN.1 编码;PEM:DER 的 Base64 文本外壳,编码格式本身不提供信任。
  • CSR:申请材料,不是证书。它带申请者公钥,并由申请者用私钥对申请内容签名(仅证明"我持有这对私钥",不证明"我拥有这个域名")。

二、三级签发:根 → 中间 → 叶子(自己业务的服务端)

1. 层次与谁信任谁

预置

R 的私钥签 DER(tbs_I)

I 的私钥签 DER(tbs_L)

部署

客户端本地信任库
(OS / 浏览器 / 运行时)

根 CA 证书 R
自签名,私钥离线

中间 CA 证书 I
由 R 私钥签发

叶子证书 L
由 I 私钥签发

线上服务器
持 ServerPrivateKey

要点:

  • 客户端预存信任根,不是因为根能自验签,而是因为根被本地信任库收录
  • 根私钥离线保管,日常签发交给中间 CA;中间挂了能吊销重发,根不至于被牵连。
  • 服务端只发"叶子 + 中间",不发根

2. 每级签的是什么

signatureValue_I = Sign( RootPrivateKey,         DER(tbsCertificate_I) )
signatureValue_L = Sign( IntermediatePrivateKey, DER(tbsCertificate_L) )

完全对称:上级的私钥下级的正文字节

3. 每级长什么样(举例)

假设有这样一条链:api.example.com 的证书由 Example TLS Intermediate CA 签发,后者由 Example Root CA 签发。

根证书 R(自签名,已在客户端信任库中)
Certificate_R {
  tbsCertificate {
    subject              = "CN=Example Root CA"
    issuer               = "CN=Example Root CA"          // 自签:issuer == subject
    validity             = 2020-01-01 .. 2040-01-01      // 常见多年,公开信任根通常远长于叶子
    subjectPublicKeyInfo = RootPublicKey                  // ECDSA P-384
    extensions {
      basicConstraints = CA:TRUE
      keyUsage         = keyCertSign, cRLSign
    }
  }
  signatureValue = Sign(RootPrivateKey, DER(tbs_R))       // 自己签自己
}
中间证书 I(由 R 签发,日常签发用)
Certificate_I {
  tbsCertificate {
    subject              = "CN=Example TLS Intermediate CA"
    issuer               = "CN=Example Root CA"           // 指向 R
    validity             = 2024-01-01 .. 2029-01-01       // 通常 5~10 年
    subjectPublicKeyInfo = IntermediatePublicKey          // ECDSA P-256
    extensions {
      basicConstraints = CA:TRUE, pathLenConstraint=0     // 下级不能再是 CA
      keyUsage         = keyCertSign, cRLSign
      extendedKeyUsage = serverAuth
      authorityKeyId   = <Root 的 subjectKeyIdentifier>    // 指认上级
    }
  }
  signatureValue = Sign(RootPrivateKey, DER(tbs_I))       // R 用其私钥签 I
}
叶子证书 L(由 I 签发,部署到服务器)
Certificate_L {
  tbsCertificate {
    subject              = "CN=api.example.com"
    issuer               = "CN=Example TLS Intermediate CA"   // 指向 I
    validity             = 2026-04-01 .. 2026-08-01           // 当前新签发上限内(≤200 天)
    subjectPublicKeyInfo = ServerPublicKey                     // ECDSA P-256
    extensions {
      subjectAltName   = DNS:api.example.com, DNS:www.example.com
      basicConstraints = CA:FALSE
      keyUsage         = digitalSignature
      extendedKeyUsage = serverAuth
      authorityKeyId   = <Intermediate 的 subjectKeyIdentifier>
      SCTList          = { SCT_log1, SCT_log2 }
    }
  }
  signatureValue = Sign(IntermediatePrivateKey, DER(tbs_L))   // I 用其私钥签 L
}

把三张证书串起来:

[ R: subject=Root,  pubkey=RootPubKey,         issuer=Root  ]   ← 自签,本地信任锚
[ I: subject=Inter, pubkey=IntermediatePubKey, issuer=Root  ]   ← 被 R 的私钥签
[ L: subject=api,   pubkey=ServerPubKey,       issuer=Inter ]   ← 被 I 的私钥签

注意:每张证书里携带的是该证书主体的公钥。上级的公钥要去上级证书里拿——这是后面验签的关键。

4. 叶子证书是怎么诞生的(ACME 流程)

CT 日志 DNS / HTTP 端点 公开 CA (ACME) 网站方 CT 日志 DNS / HTTP 端点 公开 CA (ACME) 网站方 生成密钥对,做 CSR(含公钥,可请求 SAN) 1 提交申请(含 CSR) 2 查 CAA 记录,确认自己被授权签该域 3 下发 DCV 挑战 4 放置挑战材料(常见为 HTTP-01 / DNS-01 / TLS-ALPN-01) 5 多视角校验(MPIC,BR SC-067;2025-03-15 起分阶段强制) 6 提交预证书,取回 SCT(通常嵌入证书扩展) 7 通常把 SCT 嵌入扩展,用中间私钥签发正式证书 8 返回叶子证书 + 中间证书 9

截至 2026-05-15,MPIC 已进入至少 3 个远端网络视角的阶段;2026-06-15 起提高到至少 4 个,2026-12-15 起提高到至少 5 个。

CA/Browser Forum 新签发 Subscriber Certificate 最大有效期(Ballot SC-081 v3,当前日期 2026-05-15):

签发时间 最大有效期
2026-03-15 之前 398 天
2026-03-15 至 2027-03-14(当前) 200 天
2027-03-15 至 2029-03-14 100 天
2029-03-15 起 47 天

注:这是新签发上限。2026-03-15 前已签发、notAfter 仍在未来的 398 天证书,不会因为新上限生效而立刻失效。


三、客户端怎么验证

1. 总流程

失败

失败

失败

失败

失败

失败

访问 https://api.example.com

收到服务端的证书链 L + I

拼链:L → I → 本地信任锚 R

逐级验签(见下文)

合规检查:有效期 / KU / EKU / SAN 等

主机名匹配 SAN

CertificateVerify:证明服务端持有私钥

Finished:绑定整段握手

验证通过,发送 HTTP 请求

中止连接

2. 逐级验签到底是什么逻辑

核心一句话:用上级证书里的公钥,去验下级证书的 signatureValue

为什么这样能行?签发时正是反过来:上级 CA 用自己的私钥给下级的正文签名。公私钥成对,所以拿上级的公钥就能验证这个签名。而"上级的公钥"就放在上级自己的证书里(subjectPublicKeyInfo 字段)——所以"验下级"必须"先有上级的证书"。

信任沿着链自上而下传递:

本地信任库  ──授信──▶  R(信任锚,直接被信任,不靠上级验签建立信任)
                            │ R 的私钥签过 I
                            ▼  用 R.公钥 验 I.signatureValue
                          I(验签通过 → I 可信)
                            │ I 的私钥签过 L
                            ▼  用 I.公钥 验 L.signatureValue
                          L(验签通过 → L 可信,其中的 ServerPublicKey
                             也可信地代表 api.example.com)

两点提醒:

  • R 不是靠自验签才可信。自签最多证明它的内容完整,不能凭空产生身份信任;身份信任来自本地信任库。
  • 链断在哪儿就停在哪儿。拼链时找不到上级证书,或任意一级验签失败,连接立即中止。
"验签通过"到底保证了什么

验签是一次单向数学校验,公开 TLS 证书里常见的是 RSA / ECDSA 系列签名算法。以 R 用 SHA-256 签 I 为例:

签发侧(R 持有 RootPrivateKey):
  hash_I  = SHA-256( DER(I.tbsCertificate) )
  sig_I   = Sign( RootPrivateKey, hash_I )
  把 sig_I 写进 I.signatureValue

验签侧(客户端已信任 R):
  hash_I' = SHA-256( DER(I.tbsCertificate) )       // 对收到的正文重新哈希
  ok      = Verify( RootPublicKey, hash_I', sig_I) // 用 R 证书里的公钥校验

在签名算法安全成立时,Verify 返回 true 能让客户端得到两个结论:

  1. sig_I 必须由与 RootPublicKey 配对的那把私钥生成;
  2. hash_I' 必须与签发时的 hash_I 逐位相等

于是一次性同时锁住了身份内容一致

攻击者想做 为什么做不到
不知道 R 的私钥,凑一个能验过的 sig_I 没私钥就无法生成对应公钥能验过的签名(RSA / 离散对数难题)
tbsCertificate 任一位(比如换 subject 改了字节 → hash_I' 变了 → 与原 sig_I 对不上 → Verify 失败
改正文 + 重算哈希 + 重新签 重签需要 R 的私钥,回到第 1 行
找一份"不同正文但哈希相同"的伪造正文 需破坏 SHA-256 抗碰撞性;SHA-1 被禁用正是因为这条防线已破

所以"验签通过"等价于:手上这份 tbsCertificate 字节,确实是当年握有 RootPrivateKey 的人(即 R)盖章认可的原始字节,一位没改。 正文里写着 subject = Example TLS Intermediate CAsubjectPublicKeyInfo = IntermediatePublicKey,于是这把中间公钥的身份就被钉死了,可以拿去验下一级 L。

常见误解:不要把验签理解成把 signatureValue 解开后再逐字比较正文。不同算法内部细节不同,但对调用方来说都是 Verify(公钥, 消息或消息哈希, 签名) → 布尔值 的校验;攻击者看到全部 signatureValue 字节也无法反推私钥。

3. 合规检查与主机名匹配

验签只能证明"没被改、确实由上级签出"。还要检查:

内容
有效期 叶子和中间必须在 notBefore/notAfter 之内;信任锚的有效期处理取决于客户端实现和根库策略
basicConstraints 中间必须 CA:TRUE;叶子不得是 CA(若有该扩展,应为 CA:FALSE
keyUsage / extendedKeyUsage 中间需 keyCertSign;现代 TLS 叶子通常需 digitalSignature + serverAuth(兼容旧 TLS 1.2 RSA 密钥交换时可能还会有 keyEncipherment
nameConstraints 中间 CA 不能越权签限定外的域名
算法强度 拒绝 SHA-1、过短 RSA 等
SCT Chrome、Safari 等浏览器要求公开 TLS 证书满足 CT 政策;所需 SCT 数量和日志要求随浏览器政策变化
吊销状态 取决于客户端策略;浏览器通常结合 CRL/OCSP/厂商维护的吊销列表等机制,不同客户端是否硬失败并不一致

主机名匹配应看 SAN,现代规范不再依赖 CN(RFC 9525;少数旧客户端可能仍有兼容回退):

访问目标 证书 SAN 结果
api.example.com DNS:api.example.com 通过
api.example.com DNS:*.example.com 通过
example.com DNS:*.example.com 不通过(不含裸域)
a.b.example.com DNS:*.example.com 不通过(* 只代表一个 DNS 标签,不跨点号)
192.0.2.10 iPAddress:192.0.2.10 通过
192.0.2.10 DNS:192.0.2.10 不通过(须用 iPAddress

4. 私钥归属:CertificateVerify

证书是公开的,攻击者能复制。要证明"对面这台机器真有 ServerPrivateKey",TLS 1.3 让服务端用对应私钥做一次握手签名:

// RFC 8446 §4.4.3:64 个 0x20 + 上下文串 + 0x00 + 握手哈希
toBeSigned        = 0x20 × 64
                 || "TLS 1.3, server CertificateVerify"
                 || 0x00
                 || TranscriptHash(ClientHello ... Server Certificate)
certificateVerify = Sign(ServerPrivateKey, toBeSigned)

客户端:Verify(ServerPublicKey from L, toBeSigned, certificateVerify)

前缀那 64 个 0x20 是为了防止跨协议碰撞(其他场景下不可能产生同样前缀的待签数据)。

这把"证书里的身份"绑到了"本次连接对面那台机器"。最后双方再交换 Finished(基于握手密钥的 HMAC),把整段握手钉死,防降级、防拼接。


四、延伸:嵌入式客户端把"中间 CA"作为信任锚

本节是嵌入式工程里的特殊信任锚模式,不是公开 WebPKI 客户端的默认模型。

1. 代码里如何处理

底层(mbedTLS / cm_ssl)做证书链验证时,需要一张本地"信任"的证书作为信任锚(trust anchor)。嵌入式工程把中间 CA 证书塞进信任列表,链验证在中间处即停止向上追溯:中间本身由本地配置直接授信,不再验证它是否被某个根正确签过,也不再需要根证书参与。

本地信任库  ──授信──▶  I(中间 CA,本工程的信任锚)
                            │ I 的私钥签过 L
                            ▼  用 I.公钥 验 L.signatureValue
                          L(验签通过 → L 可信)

后果:服务端链中"中间以上"的部分完全不参与(根是否过期、是否在公共信任库都无关);叶子的验签只做一次;只信任由这张特定中间签出的叶子,自带一层 CA 钉扎(CA pinning)效果。主机名匹配仍看叶子证书的 SAN;叶子的有效期、KU/EKU、basicConstraints 等检查仍应照常开启。至于信任锚证书自身的有效期、keyUsagenameConstraints 等是否以及如何检查,取决于具体 TLS 库和配置,工程上应按"会检查"来设计,并在目标固件里实测。

注:本节以 mbedTLS 类实现作参考。若使用 cm_ssl 或其他库,应单独确认信任锚加载、多信任锚选择顺序、时间校验、CRL/OCSP、basicConstraintskeyUsagenameConstraints 的实际行为。

2. 为什么会这样选

  • 省 flash:一张中间证书 ≈ 1–2 KB;完整公共根列表几十到上百 KB。
  • 省内存与启动开销:mbedTLS 的加载、解析、构链开销随信任锚数量线性增长。
  • 业务面对单一服务:设备只连一类后端,没必要让它能信"整个互联网"。
  • 顺带 pinning:即使设备厂的根列表被污染,也不影响这条链路。

3. 代价与风险

风险 说明 触发条件
中间证书到期(最大) 很多库会检查信任锚自身的 notAfter;即使库不查,服务端也可能已切到新中间 到期日;若 OTA 也依赖同一信任锚,会被锁死
中间证书被服务商轮换 服务端叶子改用另一张中间 CA 签发 任意时间,CA 自己决定
中间证书被吊销 私钥泄漏等事件触发 CA 紧急吊销;设备若检查吊销会失败,若不检查则有安全风险 罕见但发生过
服务器迁移到其他 CA 叶子完全不在这条链下 业务方决策
无法接公共 CA 的别的服务 同一份 SSL 配置只能信这一张中间签出的服务 复用配置时易踩坑

上述任一情况发生时,若设备的业务连接和 OTA 更新都依赖同一份信任锚,设备可能无法自我修复,只能通过已预留的备用信任锚或线下/旁路升级换 PEM。

4. 缓解建议

  • 监控到期日:把内置 CA 的 notAfter 写进 CI/构建产物清单,提前至少 12 个月开始告警和切换演练。
  • 预留多张信任锚:把当前在用 + 备用的中间 CA 一起编进固件,并实测目标 TLS 库的匹配顺序和失败回退;服务商轮换时无需紧急 OTA。
  • 做过渡发布:先发布包含"旧中间 + 新中间"的固件,确认服务端完成换链后,再在后续版本移除旧中间。
  • 必要时下沉到根:若 flash 允许,直接把对应的根 CA(选剩余有效期足够长的版本,例如 ECC 根 GlobalSign Root R6、ISRG Root X2 等)编进信任锚,可一次覆盖该 CA 旗下多张中间,进一步降低轮换风险。

五、常见错误速查

现象 原因 处理
certificate expired / not yet valid 证书过期或客户端时钟错 续期;校时
hostname mismatch SAN 不含访问域名 重签覆盖正确域名
unable to get local issuer certificate 服务端漏发中间证书,或客户端缺少对应上级/信任锚 补齐中间链;确认客户端信任库
untrusted root 根不在客户端信任库(如企业自签根),或信任锚不符合本地策略 安装企业根或换公开 CA 签的链
self-signed certificate in certificate chain 链中含未被信任的自签证书(多为配置错误) 改用由上级 CA 正常签发的证书
算法被拒 SHA-1、RSA < 2048 位等弱算法/弱参数 换 SHA-256+ / RSA >= 2048 / ECDSA
缺 SCT 被拒 Chrome/Safari 等浏览器要求公开 TLS 证书满足 CT 政策 用支持 CT 的公开 CA 重签

服务端链文件只放:叶子 + 必要中间(不放根)。

排查命令:

openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts
openssl x509 -in cert.pem -noout -subject -issuer -dates \
  -ext subjectAltName,extendedKeyUsage,basicConstraints
openssl verify -purpose sslserver -verify_hostname api.example.com \
  -CAfile root.pem -untrusted intermediate.pem cert.pem
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐