第3章 X.509证书结构解析

我第一次解析X.509证书时,看着那片二进制数据简直就是天书。但等你弄懂了它的结构,其实就那几块东西。说白了,证书就是一个由国际标准组织定义好的数据容器,每个字段都有它存在的理由。咱们今天就把这些字段一个一个拆开来看,顺便说点我在项目中踩过的坑。

3.1 证书整体结构概览

X.509证书最核心的设计思想,就是「谁给谁发了什么权限,在什么时间内有效」。整个证书结构可以看作一条可扩展的记录链。咱们先看一张图,把证书的骨架搭起来。

X.509 v3 证书结构核心字段 X.509 v3 证书结构 — 核心字段 TBS 证书主体区 (to‑be‑signed) 版本 (Version) 序列号 (Serial No.) 签名算法 (Sig‑Alg ID) 颁发者 (Issuer) 有效期 (Validity) 主体 (Subject) 主体公钥信息 (SubjectPubKeyInfo) 扩展 (Extensions, v3特有) 签名值 (SignatureValue) 签名算法标识 (与TBS内一致) 加密签名值 (RSA/ECDSA/SM2 等) 对 TBS 的 DER 编码进行签名 ⚠️ 注意: 验证时先解析TBS区 再用颁发者公钥验签名

这张图把证书分成了两件大事:TBS(to‑be‑signed)主体区和签名值区。TBS 区就是证书的正文,CA 对这段数据进行签名,生成签名值。验签的时候,咱们得先把 TBS 拿出来,再用颁发者的公钥去验证签名。

3.2 版本(Version)

版本字段就藏在证书的头几个字节里。X.509 证书目前有三个版本:v1、v2 和 v3。我用一个表格把它们说清楚。

版本编码值特点常见场景
v10最早期,只有基本字段,没有扩展极少见,仅历史遗留
v21引入了主体和颁发者唯一标识符几乎不用,IETF 都不推荐
v32支持扩展字段,是现代证书的基础HTTPS、代码签名、电子邮件等一切场景

我记得有一次排查一个老系统,密钥对总报验证失败。查了半天发现是个 v1 证书,没有扩展字段,程序把它当 v3 解析,导致扩展解析逻辑崩了。从那以后我写代码都会先检查版本号,再做不同处理。

你写 SDK 时,解析版本字段千万别写死成 v3。虽然绝大多数证书都是 v3,但万一遇到 v1 或 v2,你的代码得优雅降级。

3.3 序列号(Serial Number)

序列号就是证书的身份证号。CA 给每一张证书分配一个唯一正整数,一般用大端编码。为什么强调「正整数」?因为 ASN.1 对整数编码有符号位,若最高位为 1 会加 0x00 前缀。我在实际对接某云厂商时,他们的序列号正好高位为 1,导致字节长度多出一位。如果你的 SDK 粗暴地按固定偏移取长度,肯定出事。

序列号的用途很单纯——吊销时用。CRL 或 OCSP 响应里通过序列号标识被吊销的证书。建个哈希索引能大幅提升查询速度。

实践建议: 序列号至少 20 字节(160 位),保证全局唯一。解析时建议用大端无符号整数存储,长度通过 ASN.1 的 Length 字段决定,不要猜。

3.4 签名算法(Signature Algorithm)

TBS 和签名值区各有一个签名算法字段,且必须一致。为什么放两个?设计者希望 CA 为签名值区指定算法,但 TBS 内也要标注让验签更清晰。常见算法有:

  • sha256WithRSAEncryption(OID: 1.2.840.113549.1.1.11)
  • ecdsa-with-SHA256(OID: 1.2.840.10045.4.3.2)
  • SM2-with-SM3(OID: 1.2.156.10197.1.501)

你的 SDK 解析时,如果是自己写代码,可以直接用字符串匹配 OID。但线上系统遇到过一种情况:CA 把算法 OID 编码多了个 0x00 填充。嗯,这时我就去查了 ASN.1 规则,发现是 CA 实现 bug,只好硬编码兼容。

如果不知道算法 OID 怎么映射,上 oid-info.com 查。或者直接用 openssl 命令行:openssl asn1parse -in cert.pem 看签名算法字段。

3.5 颁发者(Issuer)

颁发者就是给证书签名的 CA 名称,用 X.500 的 Distinguished Name (DN) 表示。举个例子:

CN=Example Root CA, O=Example Inc., C=CN

这里面每个字段都是 ASN.1 的 RDN(相对专有名称)。解析时要把 DN 拆成有序的键值对列表。我最烦的就是有些 CA 把 DN 编码顺序打乱,但你还真不能重新排序——证书链匹配要求严格按原顺序比对。

写 SDK 时,我的做法是:解析 DN 生成一个有序列表,提供 findField("CN") 的方法,而不是直接返回拼接的字符串。这样调用方可以灵活取用。

3.6 有效期(Validity)

有效期包含两个时间戳:notBefore 和 notAfter。编码格式是 UTCTime 或 GeneralizedTime。

时间格式适用范围示例
UTCTime有效期至 2049 年250101000000Z → 2025-01-01 00:00:00 UTC
GeneralizedTime有效期至 9999 年20250101000000Z

我踩过的坑:把 UTCTime 的两位年份误算成 19xx 年。实际上 RFC 5280 规定 UTCTime 中 00–49 表示 2000–2049,50–99 表示 1950–1999。但现在的证书基本都是 2000 年后的,所以 00–49 居多。写代码时一定要按 RFC 规则处理,不要想当然。

验证有效期是 SDK 最基本的检查。但注意:notBefore 和 notAfter 都是 UTC 时间,不要用什么本地时间转换。直接统一转成 System.currentTimeMillis() 再比较,省心。

3.7 扩展字段(Extensions)—— v3 的精华

扩展字段是 v3 证书的灵魂。它让证书不只凭证身份,还能承载策略、约束、增强型密钥用法等。常见扩展有:

  • Subject Alternative Name (SAN):列出证书绑定的域名、IP、邮箱等。
  • Key Usage:限制密钥用途,如 digitalSignature、keyCertSign。
  • Basic Constraints:标记是否为 CA 证书,以及路径长度约束。
  • Extended Key Usage (EKU):进一步限制,如 serverAuth、clientAuth。
  • Certificate Policies:证书策略 OID,用于合规审计。

你的 SDK 至少需要支持解析 SAN,否则 HTTPS 客户端无法做域名匹配。我见过一个国产浏览器因为不支持解析 SAN,导致访问带 SAN 的网站报证书错误——这就是 SDK 功能缺失的典型后果。

特别注意: 扩展字段标记为 critical 的,必须被 SDK 理解和处理。如果不是 critical 的扩展,不理解可以忽略。但如果遇见不认识且是 critical 的扩展,你的 SDK 必须拒绝该证书。这是 RFC 5280 的硬性要求。

3.8 签名值(Signature Value)

签名值区存放的是 CA 对 TBS 部分 DER 编码进行签名后的结果。算法必须与 TBS 内声明的签名算法一致。验证流程很简单:

  1. 用 ASN.1 解析器提取 TBS 字段的原始 DER 字节。
  2. 用颁发者证书里的公钥,按签名算法验证这段 DER 和签名值。
  3. 如果验签通过,证书就是合法的;否则拒绝。

这里有个关键:验签必须用原始 DER 字节,而不是你重新编码的字节。我曾经有个同事自己重新序列化了 TBS,结果验签死活不过。为什么?ASN.1 编码同一段结构可以有多种合法表示(比如不定长编码 vs 定长编码),但签名时用的是CA的那份。

3.9 字段解析 CheckList

最后归纳一张清单,你写完 SDK 后可以逐项核对:

  • 版本号:取整型值,用于分支判断。
  • 序列号:取正整数,存为字节数组或无符号大整数。
  • 签名算法:OID 映射成内部枚举或字符串。
  • 颁发者 DN:解析成有序 RDN 列表,支持按键查询。
  • 有效期:UTCTime/GeneralizedTime → UTC 时间戳。
  • 主体 DN:同颁发者格式,存为有序列表。
  • 主体公钥信息:解析算法 OID + 公钥字节。
  • 扩展:至少支持 SAN、KeyUsage、BasicConstraints。
  • 签名值区:算法 + 签名值,用于顶层验签。