SAN、CN、通配符:浏览器到底按什么规则匹配证书

· 约 6 分钟 🔏 证书解析

证书绑定主机名是 HTTPS 信任链的最后一公里——证书链验证通过、证书没过期、证书不在 CRL 里,这些都对,但证书声明绑定的主机名必须和你访问的主机名能匹配才算握手成功。这套匹配规则比想象的复杂,CN 已经退场、SAN 才是当家、通配符有诸多边界。

CN 已经死了,SAN 才是事实标准

X.509 证书的 Subject 里有个 Common Name(CN)字段,最早就是给”这是发给谁的证书”用的:

Subject: C=US, ST=CA, L=San Francisco, O=Example Inc, CN=example.com

历史上浏览器会读 CN 来做主机名匹配。但这字段有两个根本问题

  1. CN 只能填一个值——一张证书要覆盖 example.comwww.example.com 没法填
  2. CN 没规定格式——可以是 “Example Inc”、“My Server”、“example.com” 任何字符串,浏览器没法自动判断哪个是域名

X.509 v3(1996)引入了 subjectAltName(SAN)扩展解决这个:

X509v3 Subject Alternative Name:
    DNS:example.com,
    DNS:www.example.com,
    DNS:api.example.com,
    IP:203.0.113.1

SAN 可以放多个、有明确类型(DNS / IP / Email / URI)、是结构化数据,从根本上解决 CN 的两个问题。

主流浏览器停用 CN 的时间线

浏览器停用 CN 时间行为
Firefox2002有 SAN 一律忽略 CN
Safari2015同 Firefox
Chrome 582017-04同 Firefox(CertificateTransparency 强制起的副作用)
Edge (Chromium)跟随 Chrome

今天写证书的铁律CN 只用作人类可读标签,主机名一律靠 SAN。自签发证书时漏掉 SAN 是最常见的坑——OpenSSL 命令行不像 GUI 那样自动补,必须在 config 里显式声明。

自签证书:SAN 必须显式声明

错误的命令(只填 CN,2017 年后浏览器全报错):

openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout key.pem -out cert.pem \
  -subj "/CN=example.com"

正确的命令(用 config 文件加 SAN):

cat > san.cnf <<EOF
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = example.com
[v3_req]
subjectAltName = DNS:example.com, DNS:*.example.com, IP:127.0.0.1
EOF

openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout key.pem -out cert.pem \
  -config san.cnf -extensions v3_req

或者用一行命令配 -addext(OpenSSL 1.1.1+):

openssl req -x509 -newkey rsa:2048 -nodes -days 365 \
  -keyout key.pem -out cert.pem \
  -subj "/CN=example.com" \
  -addext "subjectAltName=DNS:example.com,DNS:*.example.com,IP:127.0.0.1"

通配符的精确边界

*.example.com 这张证书能匹配什么、不能匹配什么:

域名能否匹配原因
example.com通配符要求至少一个标签前缀
a.example.com单层子域
www.example.com单层子域
a.b.example.com通配符只匹配单层
*.b.example.com不能用通配符递归
EXAMPLE.comDNS 大小写不敏感
xn--abc.example.comPunycode 算合法标签

RFC 6125 明确的额外限制

  1. * 必须独占整个最左标签——a*.example.com*a.example.coma*b.example.com 都被主流浏览器拒绝(虽然 RFC 6125 说”应该”接受 partial wildcard,但实际浏览器都不接受)
  2. 不能放在中间或右边——a.*.example.comexample.* 全部非法
  3. TLD 级通配禁止——*.com 这种 CA 不会签,浏览器也不接受
  4. 两层通配禁止——*.*.example.com 全员拒绝
  5. Public Suffix List 边界——*.co.uk 不会签发,因为 co.uk 是 PSL 里的”等效 TLD”

要同时覆盖裸域和子域,写两条 SAN:

DNS:example.com, DNS:*.example.com

要覆盖两级子域,要么签两张:

DNS:*.example.com
DNS:*.api.example.com

要么 SAN 里列穷举的具体子域:

DNS:user.api.example.com, DNS:order.api.example.com, ...

IP 证书:iPAddress 字段而不是 dNSName

证书绑 IP 必须用 SAN 的 iPAddress 类型,不是 dNSName

✓ subjectAltName = IP:203.0.113.1
✗ subjectAltName = DNS:203.0.113.1   ← 浏览器拒绝

OpenSSL 编码时 IP: 前缀会把字符串转成 4 字节二进制(v4)或 16 字节(v6);DNS: 前缀就是 ASCII 字符串。RFC 6125 要求 dNSName 字段值不能是 IP 字面量。

Let’s Encrypt 不签 IP 证书——它的 ACME 验证流程要求能从公网通过 HTTP-01 / DNS-01 challenge 证明所有权,IP 没法做 DNS challenge。付费 CA 部分支持公网 IP 证书:SSL.com、ZeroSSL 高级套餐、DigiCert 都签。私有 IP 一律自签——10.x、172.16-31.x、192.168.x 公开 CA 不签,因为这些 IP 不属于任何特定主体。

IPv6 证书的字面量

subjectAltName = IP:2001:db8::1

注意 dig/nslookup 出的 IPv6 地址在 SAN 里写完整或缩写都行,浏览器内部按 RFC 5952 规范化后比对。

中文域名(IDN)的 Punycode 转换

国际化域名(Internationalized Domain Name)在证书里永远存 Punycode

中国.example.com   →  xn--fiqs8s.example.com   (A-label)
日本語.example.com →  xn--wgv71a.example.com   (A-label)

签证时输入 Unicode 是不被接受的,必须先转:

import idna
idna.encode('中国.example.com')
# b'xn--fiqs8s.example.com'
# Python 一行
python3 -c "import idna; print(idna.encode('中国.example.com').decode())"

浏览器侧匹配:用户在地址栏输入 中国.example.com,浏览器按 IDNA 2008 转 Punycode 再和 SAN 比对,所以 SAN 里写 Punycode 一定能匹配 Unicode 输入。

通配符 + IDN 的坑:通配符不跨 Punycode 转换边界。*.xn--fiqs8s.example.com 这张证书匹配 a.xn--fiqs8s.example.com,浏览器也匹配 a.中国.example.com(因为先转 Punycode 再比对);但通配符本身的 * 必须放在 Punycode 后,不能写 *.中国.example.com

浏览器到底怎么决定”匹配上了”

简化的匹配流程(RFC 6125 + 各浏览器的实际行为):

1. 取访问的 host:  user-input host (string)
2. 规范化:
   - 转小写
   - IDN → Punycode
   - 去掉尾部点(trailing dot)
3. 在证书 SAN 里依次:
   - 如果 host 是 IP 字面量 → 找 iPAddress 类型,二进制比对
   - 否则 → 找 dNSName 类型,按 DNS 规则比对:
     - 完全匹配 → 通过
     - SAN 里是 *.xxx 通配符 → 检查 host 是否恰好一层子域
4. SAN 里没匹配项 → 失败(即使 CN 匹配也不算)

特别的情况

  • client cert(客户端证书)匹配的是 SAN 里的 emailAddress 或 DirectoryName,不走 dNSName
  • 代码签名证书绑定的是组织实体,不绑主机名

实战诊断速查

碰到证书报错先看 SAN:

# 看一个证书的 SAN
openssl x509 -in cert.pem -text -noout | grep -A 5 "Subject Alternative"

# 在线服务器看 SAN
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -text -noout | grep -A 5 "Subject Alternative"

# 只看域名列表
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -ext subjectAltName -noout

常见错误码:

错误含义修法
CERT_COMMON_NAME_INVALIDSAN 里没有当前 host加 SAN
ERR_CERT_AUTHORITY_INVALID证书链不完整或自签装中间证书 / 信任 CA
ERR_CERT_DATE_INVALID过期或未生效续签或检查系统时间
ERR_CERT_REVOKED在 CRL/OCSP 里被吊销重新签发
SSL_ERROR_BAD_CERT_DOMAIN (Firefox)同 CN_INVALID加 SAN

一句话总结

CN 在 2017 年后被浏览器忽略,SAN 是唯一管事的字段;*.example.com 只匹配单层子域、不匹配裸域、不能多级嵌套;IP 证书必须用 iPAddress SAN、IDN 必须用 Punycode——这四条记牢,自签证书永远不踩坑。

❓ 常见问题

为什么我配的证书里有 CN=example.com 浏览器还报 NET::ERR_CERT_COMMON_NAME_INVALID?

因为 Chrome 从 58(2017 年 4 月)开始就不再读 CN 字段做主机名匹配——必须有 SAN(Subject Alternative Name)扩展。CN 现在只用于人眼可读的显示名,做证书绑定主机名的合法字段是 X.509 v3 扩展里的 subjectAltName。Firefox 早在 2002 年就这样了,Safari 跟进,Chrome 是最后一个改的。自签发证书最常见的坑就是只填了 CN 没填 SAN——OpenSSL 1.1.1 起的命令行需要在 config 里显式加 subjectAltName = DNS:example.com,旧教程里那种只填 CN 的命令在 2017 年后所有浏览器都报错。

*.example.com 这张证书能匹配什么域名?

只匹配 example.com 的"恰好一层"子域。能匹配 a.example.comapi.example.comwww.example.com不匹配 example.com(裸域,少了一层);不匹配 a.b.example.com(多了一层);不匹配 *.b.example.com(多重通配)。要同时覆盖裸域和单层子域得在 SAN 里写两条:DNS:example.com, DNS:*.example.com。要覆盖多层子域要么签多张通配符(*.example.com + *.b.example.com),要么直接换成单域名清单。多级通配 *.*.example.com 在 RFC 6125 里被明确禁止,所有主流 CA 也不签。

证书既要绑域名又要绑 IP 怎么办?

SAN 里 dNSName 和 iPAddress 是两种不同的类型,必须分开写。OpenSSL config 里:subjectAltName = DNS:example.com, IP:1.2.3.4,编进证书后 dNSName 字段是 example.com,iPAddress 字段是 4 字节二进制 IP。Let's Encrypt 不签 IP 证书——它要求域名能 ACME challenge 才签发。ZeroSSL 部分套餐和 SSL.com 等付费 CA 支持 IP 证书,但仅限公网 IP,私有 IP(10.x / 192.168.x / 内网 DNS)只能自签。:把 IP 写在 dNSName 里浏览器仍然报错——RFC 6125 明确 dNSName 字段值是 IP 字符串时一律不视为合法 SAN。

浏览器看到 SAN 里有 xn--fiqs8s.example.com 是怎么匹配中文域名的?

证书里存的永远是 Punycode(A-label),不是 Unicode(U-label)。RFC 6125 强制 dNSName 必须是 ASCII 形式——中文域名 中国.example.com 转 Punycode 是 xn--fiqs8s.example.com,存进 SAN 时必须写后者。浏览器侧匹配流程:用户在地址栏输入 中国.example.com,浏览器先按 IDNA 2008 转 Punycode 再去和 SAN 比对。实战坑:(1) 自动化脚本签证时记得对域名做 idna.encode(),否则 SAN 里塞 UTF-8 中文,OpenSSL 默认行为是直接拒绝;(2) 通配符不能跨 Punycode 边界——*.中国.example.com 在 SAN 里写 *.xn--fiqs8s.example.com,能匹配 a.xn--fiqs8s.example.com 但不能匹配 b.中国.example.com 因为浏览器永远用 Punycode 比对。

🔏 打开 证书解析 X.509/CSR/RSA/EC 公钥解析·SAN/有效期/指纹·PEM/DER 自动识别·本地不上传

📖 同一工具的其他教程