一次完整的 TLS 证书技术拆解

从自建证书颁发机构到 ASN.1 编码:一次完整的 TLS 证书技术拆解

引言

TLS 证书的话题看似简单——生成一对密钥、签个名、扔给 Web 服务器即可——但只要沿着"为什么浏览器不信任自签名证书"这一个问题往下追问,就会依次牵出证书信任链的构建方式、浏览器验证主机名所依赖的扩展字段,以及这些字段在字节层面究竟是如何编码的。本文按这条追问路径,完整走一遍:自建证书颁发机构(Certificate Authority,CA)、SAN 扩展与浏览器验证机制的历史演变,以及 X.509 扩展字段背后的 ASN.1/DER 编码结构。


一、搭建本地可信证书颁发机构

1.1 为什么自签名证书不被信任

一条 openssl req -x509 ... 命令即可生成一张自签名证书(self-signed certificate),配上服务器完全可以正常加密流量。问题在于浏览器不会信任它,因为它没有被浏览器信任列表中的任何证书颁发机构签发。若忽略警告继续访问,且服务处于公网而非受信任的私有网络内,仍然暴露在中间人攻击(man-in-the-middle attack)风险之下——客户端始终无法验证对端身份的真实性。

解决路径是自建一个根 CA(root CA),用它签发证书,再把 CA 的证书本身加入操作系统或浏览器的信任列表,使后续所有由该 CA 签发的证书被自动信任。

1.2 生成根 CA

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out rootca.key
openssl req -x509 -key rootca.key -out rootca.crt -subj "/CN=localhost-ca/O=localhost-ca"

根 CA 的证书必然是自签名的,这是根证书颁发机构的固有性质:信任链顶端没有更上层机构为其签名,其可信度完全来自"被显式加入信任列表"这一动作本身,而非签名链条。

1.3 签发叶子证书

为待签发域名(以 localhost 为例)生成私钥:

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out localhost.key

编写配置文件 csr.conf,声明域名信息与主体备用名称(Subject Alternative Name,SAN):

[req]
distinguished_name = dn
prompt             = no
req_extensions = req_ext

[dn]
CN="localhost"

[req_ext]
subjectAltName = @alt_names

[alt_names]
DNS.0 = localhost

生成证书签名请求(certificate signing request,CSR):

openssl req -new -key localhost.key -out localhost.csr -config csr.conf

用根 CA 完成签发:

openssl x509 -req -days 365 -extensions req_ext -extfile csr.conf -CA rootca.crt -CAkey rootca.key -in localhost.csr -out localhost.crt

localhost.crt 即为受信证书,localhost.key 是其私钥,二者可直接配置给 Web 服务器使用。

1.4 让操作系统与浏览器信任 CA

  • Ubuntusudo cp rootca.crt /usr/local/share/ca-certificates/ 后执行 sudo update-ca-certificates,系统级信任即生效;但 Firefox 与 Chromium 使用各自独立的证书存储(trust store),需要单独在浏览器内导入。
  • Arch Linuxsudo trust anchor rootca.crt 一条命令即可,浏览器共用系统信任库,无需额外操作。
  • Firefox / Chromium:进入证书管理界面的"颁发机构(Authorities)"标签页手动导入 rootca.crt,并勾选信任选项,重启浏览器后 https://localhost 不再报错。

1.5 使用边界

这套方案把根 CA 的私钥(rootca.key)变成了整条信任链的唯一薄弱环节——一旦泄露,攻击者可以用它签发任意域名、且被系统信任的证书。因此自建 CA 严格限定于本地开发、测试或私有网络场景,私钥需妥善保管,不应用于生产环境或对外暴露的服务;面向公网的服务仍应使用 Let’s Encrypt 等公开受信的 CA。


二、SAN 扩展与浏览器验证机制的演变

2.1 CN 字段的历史包袱

X.509 证书标准最初为 ITU-T X.500 目录服务体系设计,Subject 字段中的通用名称(Common Name,CN)子字段本意用于承载人员或组织的可读名称。SSL 协议在 1990 年代由 Netscape 设计时,尚不存在专门承载域名、带类型标注的字段,于是直接把服务器的 DNS 主机名塞进了 CN 字段——这是对既有字段的挪用,而非规范层面的设计。

2.2 SAN 扩展的引入

IETF 随后发布面向互联网场景的 X.509 规范(PKIX 系列 RFC),在 X.509v3 扩展机制中定义了 SAN 扩展,允许证书声明多个、带明确类型标注的身份(域名、IP 地址、邮箱等)。但此时 SSL 生态早已围绕 CN 字段形成既定惯例,很多只识别单一 CN 的应用无法处理一张证书对应多个域名的场景,SAN 扩展未能立刻取代 CN,二者长期并存。

2.3 RFC 2818 的过渡期规则

2000 年发布的 RFC 2818(HTTPS 规范)明确规定:若证书中存在类型为 dNSName 的 SAN 条目,主机名验证必须优先使用该扩展;只有证书完全不含 SAN 扩展时,才允许回退(fallback)到 CN 字段。这条规则从规范层面确立了 SAN 优先、CN 兜底的双轨机制。但由于历史存量证书基数庞大,浏览器厂商此后很长时间里仍对不规范证书保持宽容,继续接受仅含 CN、缺失 SAN 的证书。

2.4 CA/Browser Forum 基线要求

2012 年,CA/Browser Forum 的基线要求(Baseline Requirements)——所有公开受信 CA 必须遵守的行业规范——开始强制要求证书必须包含 SAN 扩展。这一阶段确保了新签发证书全部规范化,但并未强制移除浏览器对旧证书 CN 字段的兼容支持。

2.5 2017 年的彻底切换

真正的转折点出现在 2017 年 4 月,Chrome 彻底移除了对 CN 字段的匹配支持。核心理由是 CN 字段语义模糊——无法区分内容究竟是域名还是组织名——这种模糊性长期是 Chrome 自身及整个 TLS 生态中安全缺陷的来源;而移除该支持的兼容性风险被认为很低,因为 RFC 2818 对 CN 回退早已明确弃用,且基线要求自 2012 年起就已强制要求 SAN 存在。其他主流浏览器随后陆续跟进这一行为。

2.6 现状

当前主流浏览器验证 HTTPS 证书时,只检查 SAN 扩展中的 dNSName 条目是否与访问的主机名匹配,完全不再读取 CN 字段。若证书缺少匹配的 SAN 条目,即使 CN 字段完全正确,连接依然会被拒绝,Chrome 给出的错误码是 ERR_CERT_COMMON_NAME_INVALID——这一错误名称本身带有误导性,容易让人误以为问题出在 CN 字段。这也是第一部分 CSR 配置文件中 [alt_names] 段落真正起作用的原因:CN="localhost" 一行如今仅具展示意义,真正决定证书能否被接受的是 subjectAltName 扩展中声明的 DNS.0 = localhost 条目。


三、X.509 扩展字段的 ASN.1/DER 编码剖析

3.1 ASN.1 与 DER 的关系

ASN.1(Abstract Syntax Notation One,抽象语法标记)是一种描述数据结构的模式语言(定义于 ITU-T X.680 系列),本身与字节表示无关;DER(Distinguished Encoding Rules,可辨别编码规则,定义于 ITU-T X.690)是将 ASN.1 抽象结构序列化为具体字节流的编码规则之一,与其并列的还有更宽松的 BER(Basic Encoding Rules,基本编码规则)和面向流式传输的 CER(Canonical Encoding Rules)。X.509 用 ASN.1 定义证书的逻辑结构,实际落盘、传输、签名时强制使用 DER 这一种编码。

为了让讨论落地,以下用实际生成的一张带 SAN 扩展的证书作为示例,通过 openssl asn1parse 直接展开其字节结构。

3.2 TLV 编码基础

DER 的每个元素都遵循标签-长度-值(Tag-Length-Value,TLV)三段式结构:

  • 标签字节(tag):最高两位表示类别(class)——00 为通用(universal,即 ASN.1 内建类型如 INTEGER、SEQUENCE),10 为上下文特定(context-specific,即 cont [n]);第三位表示基本型(primitive)还是构造型(constructed);剩余五位编码标签号。
  • 长度字段(length):短形式下单字节直接给出长度(≤127);超过该范围则用长形式,首字节最高位置 1、其余位表示后续有多少字节用来表示实际长度。

openssl asn1parse 的输出中,d 表示嵌套深度、hl(header length)表示标签与长度字段合计占用的字节数、l 表示 value 部分的字节数。

3.3 证书顶层结构实测

openssl asn1parse -in demo.crt
   0:d=0  hl=4 l= 798 cons: SEQUENCE          
   4:d=1  hl=4 l= 518 cons: SEQUENCE          
   8:d=2  hl=2 l=   3 cons: cont [ 0 ]        
  10:d=3  hl=2 l=   1 prim: INTEGER           :02
  13:d=2  hl=2 l=  20 prim: INTEGER           :2E47520A441CEAA97A30E472AFEBC12902901207
  35:d=2  hl=2 l=  13 cons: SEQUENCE          
  37:d=3  hl=2 l=   9 prim: OBJECT            :sha256WithRSAEncryption
  48:d=3  hl=2 l=   0 prim: NULL              
  50:d=2  hl=2 l=  20 cons: SEQUENCE          
  ...
 420:d=2  hl=2 l= 104 cons: cont [ 3 ]        
 422:d=3  hl=2 l= 102 cons: SEQUENCE          
 424:d=4  hl=2 l=  39 cons: SEQUENCE          
 426:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Subject Alternative Name
 431:d=5  hl=2 l=  32 prim: OCTET STRING      [HEX DUMP]:301E82096C6F63616C686F7374820B6578616D706C652E636F6D87047F000001
 465:d=4  hl=2 l=  12 cons: SEQUENCE          
 467:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Basic Constraints
 472:d=5  hl=2 l=   1 prim: BOOLEAN           :255
 475:d=5  hl=2 l=   2 prim: OCTET STRING      [HEX DUMP]:3000
 479:d=4  hl=2 l=  14 cons: SEQUENCE          
 481:d=5  hl=2 l=   3 prim: OBJECT            :X509v3 Key Usage
 486:d=5  hl=2 l=   1 prim: BOOLEAN           :255
 489:d=5  hl=2 l=   4 prim: OCTET STRING      [HEX DUMP]:030205A0

顶层结构对应 RFC 5280 中 Certificate ::= SEQUENCE { tbsCertificate, signatureAlgorithm, signatureValue }:偏移 0 处的外层 SEQUENCE 包住整张证书;偏移 4 处的 SEQUENCE(长度 518)是 tbsCertificate(待签名证书体);随后依次是版本、序列号、签名算法、颁发者、有效期、主体、公钥信息,最后在偏移 420 处出现 cont [3],对应 extensions [3] 字段。

3.4 显式标签与隐式标签

版本字段(偏移 8)与扩展字段(偏移 420)都表现为 cont [n] 包裹着一个普通的通用类型(INTEGER / SEQUENCE)——这是显式标签(explicit tagging):上下文标签额外包一层,内部仍保留原始通用类型的标签。RFC 5280 的 ASN.1 模块整体采用隐式标签(implicit tagging)作为默认标签环境,但专门为 versionextensions 这两个字段声明了 EXPLICIT,以避免歧义。第 3.6 节展开 SAN 内部结构时会看到相反的情形。

3.5 Extension 结构与 DER 的省略规则

extensions [3] 内部是 Extensions ::= SEQUENCE OF Extension,每个 Extension 定义为:

Extension ::= SEQUENCE {
    extnID     OBJECT IDENTIFIER,
    critical   BOOLEAN DEFAULT FALSE,
    extnValue  OCTET STRING
}

对照偏移 424 处的 SAN 扩展:OBJECT(长度 3,即 OID 2.5.29.17)后面直接跟着 OCTET STRING,中间没有 BOOLEAN 字段。这不是遗漏,而是 DER 的强制要求:critical 是带 DEFAULT FALSE 的可选字段,DER 规定凡取默认值的可选字段必须完全省略其编码,不允许显式写出一个值为 FALSE 的 BOOLEAN。

对照偏移 465 处的 Basic Constraints 扩展,则能看到 BOOLEAN :255 确实出现了(生成证书时将其标记为 critical)。这里还有一处 DER 特有的收紧规则:BER 允许 BOOLEAN 的 TRUE 用任意非零字节表示,DER 则强制 TRUE 必须编码为字节 0xFF(十进制 255,与输出中的 :255 吻合)。逐字节核对长度也能验证结构无误:Basic Constraints 的 SEQUENCE 长度为 12 = OID(2+3 字节)+ BOOLEAN(2+1 字节)+ OCTET STRING(2+2 字节),恰好对上;Key Usage 同理,14 = 5 + 3 + 6。

extnValue 本身是一个 OCTET STRING,其内容是另一次独立的 DER 编码,具体结构由扩展类型自身的 ASN.1 定义决定。通用解析器并不知晓这层嵌套,只会把它当作不透明字节串显示为十六进制,因此需要用 -strparse 指定偏移量手动"下钻"进这段字节:

openssl asn1parse -in demo.crt -strparse 431
   0:d=0  hl=2 l=  30 cons: SEQUENCE          
   2:d=1  hl=2 l=   9 prim: cont [ 2 ]        
  13:d=1  hl=2 l=  11 prim: cont [ 2 ]        
  26:d=1  hl=2 l=   4 prim: cont [ 7 ]        

3.6 SAN 内部结构逐字节还原

这段内容对应 SubjectAltName ::= GeneralNames,即 GeneralNames ::= SEQUENCE OF GeneralName,而 GeneralName 是一个 CHOICE 类型:

GeneralName ::= CHOICE {
    otherName                 [0] ...,
    rfc822Name                [1] IA5String,
    dNSName                   [2] IA5String,
    ...
    uniformResourceIdentifier [6] IA5String,
    iPAddress                 [7] OCTET STRING,
    ...
}

原始十六进制 301E82096C6F63616C686F7374820B6578616D706C652E636F6D87047F000001 按 TLV 逐段拆解:

  • 82 09 + 9 字节 6C6F63616C686F7374 → 上下文标签 [2]dNSName),ASCII 解码为 localhost
  • 82 0B + 11 字节 6578616D706C652E636F6D → 同样是 [2],ASCII 解码为 example.com
  • 87 04 + 4 字节 7F000001 → 上下文标签 [7]iPAddress),原始字节直接就是 127.0.0.1(未做 ASCII 编码,而是网络字节序的原始地址位)

这里体现的正是与前面版本号、扩展字段相反的隐式标签:dNSName [2] IA5String 中的上下文标签 [2] 直接取代IA5String 本应携带的通用标签(0x16),而不是像显式标签那样额外包一层。字节流中因此看不到任何 IA5String 的标签痕迹,只有裸露的上下文标签加原始字符内容——通用解析器只能显示 cont [2],无法进一步识别具体类型,必须结合 RFC 5280 的模式定义才能正确解读。

3.7 OID 的变长编码

OID 2.5.29.17subjectAltName 的对象标识符)编码为 3 字节 55 1D 11(与输出中的 l=3 吻合)。DER 对 OID 采用 base-128 变长编码:前两个弧(arc)按公式 40×X+Y 合并进第一个字节(40×2+5=85=0x55),后续每个弧若小于 128 则各占一字节(29=0x1D17=0x11),大于等于 128 时才需要用多字节、每字节最高位作延续标志位的变长形式。

3.8 为什么必须是 DER 而非 BER

BER 允许同一个抽象值存在多种合法字节编码——例如不定长格式(indefinite length,用结束标记而非显式长度)、非最短形式的长度字段、SET 内元素顺序不固定等。签名算法计算的对象是 tbsCertificate 这段字节流本身的哈希值,若编码不唯一,同一份逻辑内容就可能对应多种字节表示,从而破坏"字节流与签名一一对应"这一前提,衍生出编码可塑性(encoding malleability)问题。DER 通过强制唯一的规范形式(本文出现过的省略默认值字段、BOOLEAN TRUE 必须为 0xFF、长度字段必须使用最短形式等规则,均属此类约束)消除了这种歧义,这正是 CA 签名与验证流程必须锚定在 DER 而非更宽松的 BER 之上的根本原因。


参考资料

  • Previnder, Setting up a trusted, self-signed SSL/TLS certificate authority in Linux
  • RFC 5280, Internet X.509 Public Key Infrastructure Certificate and CRL Profile
  • RFC 2818, HTTP Over TLS
  • ITU-T X.680 系列(ASN.1 语法定义)
  • ITU-T X.690(BER/CER/DER 编码规则)
  • CA/Browser Forum, Baseline Requirements for the Issuance and Management of Publicly-Trusted Certificates