第3章 X.509证书结构解析
我第一次解析X.509证书时,看着那片二进制数据简直就是天书。但等你弄懂了它的结构,其实就那几块东西。说白了,证书就是一个由国际标准组织定义好的数据容器,每个字段都有它存在的理由。咱们今天就把这些字段一个一个拆开来看,顺便说点我在项目中踩过的坑。
3.1 证书整体结构概览
X.509证书最核心的设计思想,就是「谁给谁发了什么权限,在什么时间内有效」。整个证书结构可以看作一条可扩展的记录链。咱们先看一张图,把证书的骨架搭起来。
这张图把证书分成了两件大事:TBS(to‑be‑signed)主体区和签名值区。TBS 区就是证书的正文,CA 对这段数据进行签名,生成签名值。验签的时候,咱们得先把 TBS 拿出来,再用颁发者的公钥去验证签名。
3.2 版本(Version)
版本字段就藏在证书的头几个字节里。X.509 证书目前有三个版本:v1、v2 和 v3。我用一个表格把它们说清楚。
| 版本 | 编码值 | 特点 | 常见场景 |
|---|---|---|---|
| v1 | 0 | 最早期,只有基本字段,没有扩展 | 极少见,仅历史遗留 |
| v2 | 1 | 引入了主体和颁发者唯一标识符 | 几乎不用,IETF 都不推荐 |
| v3 | 2 | 支持扩展字段,是现代证书的基础 | 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 内声明的签名算法一致。验证流程很简单:
- 用 ASN.1 解析器提取 TBS 字段的原始 DER 字节。
- 用颁发者证书里的公钥,按签名算法验证这段 DER 和签名值。
- 如果验签通过,证书就是合法的;否则拒绝。
这里有个关键:验签必须用原始 DER 字节,而不是你重新编码的字节。我曾经有个同事自己重新序列化了 TBS,结果验签死活不过。为什么?ASN.1 编码同一段结构可以有多种合法表示(比如不定长编码 vs 定长编码),但签名时用的是CA的那份。
3.9 字段解析 CheckList
最后归纳一张清单,你写完 SDK 后可以逐项核对:
- 版本号:取整型值,用于分支判断。
- 序列号:取正整数,存为字节数组或无符号大整数。
- 签名算法:OID 映射成内部枚举或字符串。
- 颁发者 DN:解析成有序 RDN 列表,支持按键查询。
- 有效期:UTCTime/GeneralizedTime → UTC 时间戳。
- 主体 DN:同颁发者格式,存为有序列表。
- 主体公钥信息:解析算法 OID + 公钥字节。
- 扩展:至少支持 SAN、KeyUsage、BasicConstraints。
- 签名值区:算法 + 签名值,用于顶层验签。