证书绑定主机名是 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 来做主机名匹配。但这字段有两个根本问题:
- CN 只能填一个值——一张证书要覆盖
example.com和www.example.com没法填 - 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 时间 | 行为 |
|---|---|---|
| Firefox | 2002 | 有 SAN 一律忽略 CN |
| Safari | 2015 | 同 Firefox |
| Chrome 58 | 2017-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.com | ✓ | DNS 大小写不敏感 |
xn--abc.example.com | ✓ | Punycode 算合法标签 |
RFC 6125 明确的额外限制:
*必须独占整个最左标签——a*.example.com、*a.example.com、a*b.example.com都被主流浏览器拒绝(虽然 RFC 6125 说”应该”接受 partial wildcard,但实际浏览器都不接受)- 不能放在中间或右边——
a.*.example.com、example.*全部非法 - TLD 级通配禁止——
*.com这种 CA 不会签,浏览器也不接受 - 两层通配禁止——
*.*.example.com全员拒绝 - 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_INVALID | SAN 里没有当前 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——这四条记牢,自签证书永远不踩坑。