本章导读
第 78 章学会了读 TLV;本章用它读最重要的 DER 文档——X.509 证书。一张证书是一个 CA 对某公钥的背书:"这个公钥属于名叫 X 的主体,由名叫 Y 的签发者在某时段内担保"。xrtX509Parse 一步把 DER 变成 xx509cert 视图:版本、序列号、签发者、主体、有效期、公钥、扩展、签名——全部借用原 DER,零拷贝零分配。本章讲清视图的每个字段、Name 的 RDN 游标、扩展的遍历方式、以及"签名算法 OID → 规范化参数"的翻译层;解析器内建的消费侧校验清单(结构、编码规范、字段顺序)也会完整列出。验证(签名是否成立)是第 80 章的事——本章只负责把证书说了什么读准确。
79.1引入
TLS 客户端收到服务端证书后的第一件事是什么?不是验证签名——是解析。你的代码要回答一连串问题:这张证书是谁签发的(找上一级)?主体叫什么(身份匹配)?有效期到哪天(过期检查)?公钥是什么类型(选验证后端)?带了哪些扩展(找 SAN、找吊销地址)?每个问题都对应 xx509cert 的一两个视图字段。
解析器自己是道防线:一张格式非法的证书(字段顺序错、长度越界、非规范编码、重复扩展 OID)在 Parse 这一步就被拒绝——不会等你验证签名时才崩溃。RFC 5280 的证书语法树有几十个节点,XRT 把它们全部内化:你拿到的是经过完整校验的视图,而不是裸 TLV 自己摸索。
79.2概念
证书的结构:一个签名包着一段声明
三个组成部分的关系是第 80 章的核心:signatureValue 是对 tbsCertificate 整段 DER 字节的签名——验证时取的就是这段原文。xx509cert 保留了完整证书与 TBS 的 DER(Raw/Tbs)、TBS 与外层两个 AlgorithmIdentifier(要求两者一致——历史上有证书两者不同,属解析拒绝项)、以及去掉了 unused-bit 的签名内容。
视图全家:一张证书读出什么
xrtX509Parse(DER, 长度, &证书视图) 成功后可用的事实:
| 字段 | 内容 | 备注 |
|---|---|---|
Version | v1/v2/v3 自然编号 | 字段缺省即 v1(枚举值 1),不写 0 |
Serial | 序列号完整内容八位组 | 借用视图,保留正数符号位、不转幅值——消费侧接受零/负数/超长历史值 |
Issuer/Subject | 签发者/主体的完整 DER | Name 结构,见下一节 |
NotBefore/NotAfter | 有效期,已转 xtime | 闭区间;xrtX509ValidAt(&cert, xrtNow()) 一步判过期 |
SPKI | SubjectPublicKeyInfo 完整 DER | 公钥算法+素材,见下下节 |
Extensions | 扩展序列(可选,v3) | 游标遍历,见后 |
Signature | 签名内容(BIT STRING 去位偏移) | 供第 80 章验证 |
两条契约值得单独记:有效期是闭区间且参数合法时"过期"只返回 false 而不是运行错误(过期是正常业务状态,不是解析故障);Serial 保持原样字节——RFC 5280 要求 CA 用正数且不超 20 字节,但消费侧必须妥善处理现实根库里存在的零与负数序列号,签发器约束是 CA 侧工具的事。
Name:主体不是字符串,是属性序列
证书里的 Issuer/Subject 不是"一个名字",而是 RDN(相对可分辨名)序列——C=CN, O=Example, CN=api.example.test 这类属性组。xrtX509NameInit/Read 游标逐条产出属性:OID(标识这是国家/组织/通用名)+ 值视图。两个 Name 的相等性按 DER 字节比较(不是字符串比较)——第 81 章链验证里"子证书的 Issuer == 父证书的 Subject"就是精确字节匹配。重要边界:现代身份判断不看 CN(Common Name)——RFC 9525 明确只认 SubjectAltName(第 80 章),CN 只是显示用途。
公钥与签名算法:OID 翻译层
证书里的算法全用 OID(一串数字如 1.2.840.113549.1.1.10)标识。XRT 不让你维护 OID 对照表:
xrtX509PublicKey(&cert, &公钥视图):解析 SPKI——产出Type枚举(RSA、RSA-PSS、EC、Ed25519、Ed448 与两条 x25519/x448 曲线族,独立于当前编入的后端)与算法参数、公钥素材视图。xrtX509AlgorithmParse(DER 视图, &算法)+xrtX509SignatureParse(&算法, &规范签名):把 AlgorithmIdentifier 翻译成规范化参数结构——签名类型(如 RSA-PSS)、内容摘要、MGF1 摘要、盐长、trailer——第 80 章的验证入口直接吃这个结构。参数缺省按 RFC 默认补齐;非法组合在解析层就报错。
扩展:证书的插件系统
v3 证书的扩展区是键值对序列:OID + critical 位 + 值 DER。解析器检查重复 OID 与结构合法性;遍历用扩展游标逐条产出。常用的扩展:SAN(主体备用名——DNS/IP 身份的真正来源)、BasicConstraints(是不是 CA、路径深度上限——链验证的核心输入)、KeyUsage(密钥用途位图)、AKI/SKI(签发者/主体密钥标识——链查找的索引)、CRL 分发点(第 81 章吊销检查的入口)。每个扩展的值又是各自的 DER 结构——XRT 为 SAN、BasicConstraints、NameConstraints、CRL 相关扩展提供专门解析入口(docs/api/x509.md 各节),本章速查表列出索引。
解析器验证清单:消费侧的严格
Parse 拒绝的输入:字段数量与顺序错误、显式默认版本、非规范序列号 INTEGER、Issuer 为空、时间编码非真实日历、NotAfter 早于 NotBefore、SPKI 结构错、可选字段与版本矛盾(v1 证书带 v3 字段)、扩展正文 DER 非法、重复扩展 OID、TBS 与外层签名算法不一致、签名 BIT STRING 非法、空 Subject 未配非空 critical SAN。这张清单是"消费侧严格":一切现实世界存在的合法变体(零序列号、超长序列号)都被接受,一切结构歧义都被拒绝——能解析 = 结构无歧义,剩下的真伪判断交给验证层。
79.3示例
第一个完整程序:零分配解析与主体遍历
下面的程序来自 examples/x509/inspect/main.c,解析一张内置的最小证书并读出版本、主体属性与公钥类型:
#include <stdio.h> #include <xrt.h> */ /* 演示零分配解析证书,并遍历其 Subject Name。 */ int main(void) { static const uint8 Certificate[] = { 0x30, 0x68, 0x30, 0x5C, 0x02, 0x01, 0x01, 0x30, 0x04, 0x06, 0x02, 0x2A, 0x03, 0x30, 0x11, 0x31, 0x0F, 0x30, 0x0D, 0x06, 0x03, 0x55, 0x04, 0x03, 0x0C, 0x06, 0x49, 0x73, 0x73, 0x75, 0x65, 0x72, 0x30, 0x1E, 0x17, 0x0D, 0x32, 0x35, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5A, 0x17, 0x0D, 0x32, 0x36, 0x30, 0x31, 0x30, 0x31, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x5A, 0x30, 0x12, 0x31, 0x10, 0x30, 0x0E, 0x06, 0x03, 0x55, 0x04, 0x03, 0x0C, 0x07, 0x53, 0x75, 0x62, 0x6A, 0x65, 0x63, 0x74, 0x30, 0x0A, 0x30, 0x04, 0x06, 0x02, 0x2A, 0x03, 0x03, 0x02, 0x00, 0x4B, 0x30, 0x04, 0x06, 0x02, 0x2A, 0x03, 0x03, 0x02, 0x00, 0x53 }; xx509cert Cert; xx509namecursor Cursor; xx509nameattr Attribute; xx509pubkey PublicKey; if ( !xrtX509Parse(Certificate, sizeof(Certificate), &Cert) || !xrtX509NameInit(Cert.Subject, &Cursor) || (xrtX509NameRead(&Cursor, &Attribute) != X509_VALUE) || !xrtX509PublicKey(&Cert, &PublicKey) ) { return 1; } printf( "version=%d subject=%.*s key-type=%d\n", (int)Cert.Version, (int)Attribute.Value.Size, (const char*)Attribute.Value.Data, (int)PublicKey.Type ); return 0; }
刚才发生了什么。 ① 输入是 104 字节的手工最小证书(数组直接内嵌)——xrtX509Parse 一步通过全部结构校验并产出视图;没有堆分配,Cert 里所有指针都指向数组内部。② xrtX509NameInit + Read 游标取 Subject 的第一条属性——值视图 Subject(这张证书的 CN);RDN 遍历与第 78 章 DER 游标同构,只是属性语义被翻译好了。③ xrtX509PublicKey 给出 Type=0(X509_KEY_UNKNOWN——这张玩具证书的公钥算法 OID 不在已知集合),真实证书会是 RSA/EC/ED25519 等枚举值。④ 打印完直接返回——视图借用数组,无需任何释放调用;真实场景换成"PEM 解码出的 DER 缓冲"时,视图寿命与那个缓冲绑定(见坑 1)。
第二个完整程序:签名算法参数翻译
第二个程序来自 examples/x509/signature/main.c,从独立的 AlgorithmIdentifier 读出 RSA-PSS 全套参数:
#include <stdio.h> #include <xrt.h> */ /* 演示从独立 AlgorithmIdentifier 读取完整 RSA-PSS 参数。 */ int main(void) { static const uint8 AlgorithmDer[] = { 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0A, 0x30, 0x00 }; xx509algorithm Algorithm; xx509signature Signature; if ( !xrtX509AlgorithmParse( (xbytesview) { AlgorithmDer, sizeof(AlgorithmDer) }, &Algorithm ) || (xrtX509SignatureParse( &Algorithm, &Signature ) != X509_VALUE) ) { return 1; } printf( "type=%d hash=%d mgf-hash=%d salt=%zu trailer=%u\n", (int)Signature.Type, (int)Signature.Hash, (int)Signature.MaskHash, Signature.SaltSize, (unsigned)Signature.Trailer ); return 0; }
刚才发生了什么。 ① 输入 15 字节:SEQUENCE 包着 OID 1.2.840.113549.1.1.10(id-RSASSA-PSS)与空参数 SEQUENCE。② 两步翻译:AlgorithmParse 识别 OID 产算法标识,SignatureParse 把参数(含缺省补齐)填成规范结构——type=2 是 RSA-PSS 枚举、hash=1 是 SHA-256 族、MGF1 同哈希、盐长 20 字节(RFC 4055 缺省)、trailer 0xBC。③ 调用方拿到的是可直接驱动第 76 章密码模块的参数——不用自己查 OID 表、不用管缺省规则,这就是"OID 翻译层"的意义。证书场景里这两步被 CertificateVerify(第 80 章)内化,但独立入口对 CRL、OCSP、自定义签名对象同样可用。
79.4契约
- 零拷贝视图:
xx509cert全部字段借用输入 DER;输入内存在视图用完前必须有效;无任何分配,也就无释放调用。 - 结构即校验:解析通过 = 字段顺序/编码/一致性全部合法(消费侧清单);结构歧义在
Parse拒绝,错误域xrt.x509。 - 有效期:
xtime形态、闭区间;xrtX509ValidAt参数合法时过期/未生效只是false,不是错误。 - Serial 语义:保留规范 INTEGER 原样内容(含符号位);CA 侧约束(正数、≤20 字节)由签发/合规工具另行执行。
- Name 相等:按 DER 字节比较(
xrtX509NameEqual),不是字符串等价;身份判断不读 CN(第 80 章 SAN 规则)。 - 算法翻译:AlgorithmIdentifier → 规范参数在解析层完成;缺省按 RFC 补齐;非法组合报错。
- 公钥类型:
xx509keytype独立于编入的验证后端——解析不需要密码算法,验证(第 80 章)按需裁剪。 - 扩展:重复 OID 与结构非法在解析拒绝;SAN/BasicConstraints/NameConstraints/CRL 扩展有专门入口(
docs/api/x509.md)。
常见错误
症状:解析成功、字段也都读到了,过一会儿访问视图崩溃或读到垃圾——典型在"函数内解码 PEM、返回证书视图"的封装里。
原因:xx509cert 只是指向 DER 的指针集合。DER 缓冲(无论是 PEM 解码产物还是读入的文件内容)一旦释放,全部视图悬空。
xx509cert* load_cert(const char* sPemText) { bytes pDer = ...; /* 本地 DER 缓冲 */ xx509cert* pCert = (xx509cert*)xrtMalloc(sizeof(*pCert)); xrtX509Parse(pDer, iSize, pCert); return pCert; /* pDer 生命周期结束:返回的视图全部悬空 */ }
/* 谁持有 DER,谁持有视图——把缓冲与视图绑在同一作用域 */ typedef struct { bytes pDer; size_t iSize; xx509cert Cert; } loadedcert; bool load_cert(const char* sPemText, loadedcert* pOut) { 两者同生共死,由调用方一起释放/持有 */ }
症状:证书 SAN 里有 api.example.test,但 CN 是 Example Ltd——按 CN 匹配的代码要么匹配失败(好情况),要么被恶意证书的 CN 字段骗过(坏情况):攻击者自签一张 CN 写成目标域名的证书。
原因:RFC 9525 废止了 CN 回退——服务身份只看 SubjectAltName 的 DNS-ID/IP-ID。CN 是给人看的显示名,攻击者可以随便填。
/* 遍历 Subject 找 CN 属性,拿值与主机名比较 */ if ( strcmp(cn_value, sHost) == 0 ) { accept(); } /* CN 完全由证书作者控制:这不是身份验证 */
/* 身份匹配走 SAN:xrtX509MatchHost(第 80 章)只读 DNS-ID/IP-ID */ xx509genname Presented; if ( xrtX509MatchHost(&Cert, XRT_STR_LITERAL(sHost), &Presented) != X509_VALUE ) { reject(); /* 未命中 SAN:拒绝,绝不回退 CN */ }
症状:Version == 3 永远为假(v3 的枚举值是 3 但写成了字符串/位判断),或者对 v1 证书访问 Extensions 视图为空还继续遍历。
原因:版本是自然编号枚举(v1=1、v2=2、v3=3),且字段缺省就是 v1——老证书没有显式版本字节。扩展只存在于 v3;对 v1/v2 证书做扩展遍历要先判版本。
if ( Cert.Version & 3 ) { /* 位判断:语义完全错误 */ xrtX509ExtensionInit(&Cert, &Cursor); /* v1 证书:无扩展 */ while ( /* 遍历 */ ); }
if ( Cert.Version >= X509_VERSION_3 ) { /* v3:扩展存在,安全遍历 */ } else { 这类证书在现代 TLS 里就该被拒绝 */ }
编程练习
扩展 inspect 示例:除版本/主体/公钥外,再打印 Issuer 的第一条 RDN、NotBefore/NotAfter 的年份、xrtX509ValidAt(&Cert, xrtNow()) 的结果。验收标准:与手推 DER(对照 RFC 5280 结构图)一致;玩具证书的有效期结论(很可能"已过期")能解释。
写一个函数 dump_cert(loadedcert* pC):打印序列号(HEX、逐字节)、签名算法翻译结果(type/hash/盐长)、公钥类型枚举名。用 OpenSSL 生成三张不同证书(RSA-PSS、ECDSA P-256、Ed25519 各一),对每张运行并对照 openssl x509 -text 输出。验收标准:三张证书的算法参数与 OpenSSL 显示一致;序列号逐字节一致(含前导零保留)。
实现"证书体检":遍历全部扩展,输出 OID、critical 位、已识别扩展的语义摘要(SAN 的 DNS 列表、BasicConstraints 的 CA 标志与深度、KeyUsage 位图、CRL 分发点 URL),未识别扩展标记 unknown。用它检查一张真实站点证书。验收标准:识别扩展的摘要与 openssl x509 -text 一致;critical 且未识别的扩展要高亮警告(第 81 章会说明为什么这该导致验证失败)。
小结
| 知识点 | 速查 |
|---|---|
| 结构 | Certificate = tbsCertificate + signatureAlgorithm + signatureValue(对 TBS 原文签名) |
| 一步解析 | xrtX509Parse(DER, 长度, &视图);零分配零释放;结构校验内建 |
| 视图字段 | Raw/Tbs/Version/Serial/Issuer/Subject/NotBefore/NotAfter/SPKI/Extensions/Signature |
| 版本 | 自然编号(v1=1 缺省值/v2=2/v3=3);扩展仅 v3 |
| 有效期 | xtime 闭区间;xrtX509ValidAt(&cert, xrtNow()) 一步判断;过期≠错误 |
| Serial | 原样内容字节(保留符号位与超长值);CA 侧约束另行执行 |
| Name 游标 | NameInit/Read 逐 RDN 属性;相等按 DER 字节;身份不读 CN |
| 公钥 | xrtX509PublicKey → 类型枚举 + 素材;类型独立于验证后端编入 |
| 算法翻译 | AlgorithmParse+SignatureParse → 规范参数(type/hash/mgf/盐/trailer) |
| 扩展 | 游标遍历;SAN/BasicConstraints/NameConstraints/CRL 扩展有专门入口 |
| 视图寿命 | 与输入 DER 缓冲绑定;封装时缓冲与视图同生共死 |
📖 本章涉及的 API 参考手册:x509(140 个条目)asn1(35 个条目)pem(11 个条目)
点击查看完整签名、参数约束、返回值与错误说明