4、证书验证实战:使用 OpenSSL 验证证书链、使用 Python 的 ssl 库验证对端证书、常见验证失败原因分析
理论讲完了,咱们来点实在的。
证书验证这件事,说白了就是回答三个问题:
- 这个证书是不是真的?
- 发证书的人我信不信?
- 这个证书是不是给这个网站用的?
我见过太多人,代码写完了,证书验证却跳过了。结果呢?生产环境一出事,抓包一看,中间人攻击。嗯,那场面挺尴尬的。
核心原则:永远不要在生产环境中关闭证书验证。永远不要。
4.1 使用 OpenSSL 验证证书链
OpenSSL 是 TLS 世界的瑞士军刀。我个人习惯,拿到一个证书,先用它手动走一遍验证流程。
4.1.1 查看证书内容
先看看证书里有什么。别急着信,先拆开看。
# 查看 PEM 格式证书的详细信息
openssl x509 -in server.crt -text -noout
# 只看有效期
openssl x509 -in server.crt -dates -noout
# 只看 Subject 和 Issuer
openssl x509 -in server.crt -subject -issuer -noout
输出里你会看到 Issuer(签发者)和 Subject(持有者)。这两个字段,就是信任链的起点和终点。
4.1.2 手动验证证书链
假设你有三个文件:server.crt(服务器证书)、intermediate.crt(中间证书)、root.crt(根证书)。
第一步,验证服务器证书是由中间证书签发的:
openssl verify -CAfile intermediate.crt server.crt
第二步,验证中间证书是由根证书签发的:
openssl verify -CAfile root.crt intermediate.crt
第三步,一次性验证整条链:
# 把中间证书和根证书拼在一起
cat intermediate.crt root.crt > chain.crt
# 验证服务器证书
openssl verify -CAfile chain.crt server.crt
如果输出是 server.crt: OK,说明链是完整的。
小技巧:我曾经遇到一个客户,证书链总是不完整。查了半天,发现他把中间证书和根证书的顺序搞反了。OpenSSL 要求从叶子到根的顺序,别弄反了。
4.1.3 模拟验证失败场景
故意制造一个错误,看看 OpenSSL 怎么报错。这招我常用,能帮你快速理解错误信息。
# 场景1:证书过期(修改系统时间,或者用 -attime 参数)
openssl verify -CAfile chain.crt -attime 1700000000 server.crt
# 场景2:自签名证书(没有 CA 签发)
openssl verify server.crt
# 场景3:域名不匹配(OpenSSL 本身不验证域名,但可以看 SAN)
openssl x509 -in server.crt -text -noout | grep -A 1 "Subject Alternative Name"
4.2 使用 Python 的 ssl 库验证对端证书
Python 的 ssl 库底层调用的就是 OpenSSL。所以,理解 OpenSSL 的验证逻辑,Python 这边就顺了。
4.2.1 基本验证示例
import ssl
import socket
hostname = 'www.example.com'
port = 443
# 创建默认的 SSL 上下文
context = ssl.create_default_context()
# 建立连接
with socket.create_connection((hostname, port)) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
# 获取对端证书
cert = ssock.getpeercert()
print("证书验证成功")
print(f"Subject: {cert['subject']}")
print(f"Issuer: {cert['issuer']}")
print(f"有效期: {cert['notBefore']} - {cert['notAfter']}")
这段代码,Python 会自动完成三件事:
- 验证证书链是否完整
- 检查证书是否过期
- 验证主机名是否匹配
4.2.2 自定义 CA 证书包
有时候你需要用自己的 CA 证书。比如公司内部 PKI,或者测试环境。
# 使用自定义 CA 证书
context = ssl.create_default_context(cafile="my_ca.crt")
# 或者使用 CA 证书目录
context = ssl.create_default_context(capath="/etc/ssl/certs")
# 加载多个 CA 证书
context.load_verify_locations(cafile="chain.crt")
注意:不要用 ssl._create_unverified_context()。我见过有人为了省事,直接跳过验证。结果呢?生产环境被中间人攻击,数据泄露。这个坑,别踩。
4.2.3 手动验证域名
Python 的 ssl 库会自动验证域名。但如果你想手动做,可以这样:
import ssl
import socket
# 获取证书但不验证域名
context = ssl.create_default_context()
context.check_hostname = False
with socket.create_connection(('www.example.com', 443)) as sock:
with context.wrap_socket(sock, server_hostname='www.example.com') as ssock:
cert = ssock.getpeercert()
# 手动验证域名
def verify_hostname(cert, hostname):
# 检查 SAN
san = cert.get('subjectAltName', [])
for entry_type, value in san:
if entry_type == 'DNS':
if value == hostname:
return True
# 支持通配符
if value.startswith('*.'):
if hostname.endswith(value[1:]):
return True
return False
if verify_hostname(cert, 'www.example.com'):
print("域名匹配")
else:
print("域名不匹配")
4.3 常见验证失败原因分析
证书验证失败,原因就那么几种。我整理了一个表格,方便你快速定位。
| 错误类型 | OpenSSL 错误信息 | Python 异常 | 原因 |
|---|---|---|---|
| 证书过期 | certificate has expired | ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate has expired | 证书有效期已过,或者系统时间不对 |
| 自签名证书 | self-signed certificate in certificate chain | ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] self-signed certificate | 证书是自己签的,不在信任库中 |
| 域名不匹配 | (OpenSSL 不报错,但浏览器会) | ssl.CertificateError: hostname 'xxx' doesn't match 'yyy' | 证书的 CN 或 SAN 与访问的域名不一致 |
| 证书链不完整 | unable to get local issuer certificate | ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] unable to get local issuer certificate | 服务器没有发送中间证书 |
| 根证书不受信任 | unable to verify the first certificate | ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed | 签发该证书的 CA 不在信任库中 |
4.3.1 证书过期
这个最常见。我遇到过好几次,半夜被叫起来,说服务连不上了。一看,证书过期了。
检查方法:
# 查看证书有效期
openssl x509 -in server.crt -dates -noout
# 输出示例
# notBefore=Jan 1 00:00:00 2023 GMT
# notAfter=Dec 31 23:59:59 2023 GMT
如果系统时间不对,也会导致验证失败。用 date 命令检查一下系统时间。
我的经验:建议在证书到期前 30 天设置自动提醒。我一般用脚本每天检查一次,到期前发邮件。别等到过期了再处理,那太被动了。
4.3.2 自签名证书
自签名证书,说白了就是自己给自己发证。没有 CA 背书,所以默认不被信任。
什么时候用自签名?
- 开发环境
- 内部测试
- 公司内部服务(配合内部 CA)
解决方案:
- 把自签名证书添加到信任库
- 或者使用内部 CA 签发证书
# 把自签名证书添加到系统信任库(Linux)
sudo cp server.crt /usr/local/share/ca-certificates/
sudo update-ca-certificates
# 或者在 Python 中指定 CA 文件
context = ssl.create_default_context(cafile="server.crt")
4.3.3 域名不匹配
这个错误,说白了就是证书上的域名和你访问的域名对不上。
检查方法:
# 查看证书的 CN 和 SAN
openssl x509 -in server.crt -text -noout | grep -E "Subject:|DNS:"
常见原因:
- 证书是给
www.example.com签的,你访问的是api.example.com - 通配符证书
*.example.com不能匹配example.com(注意,没有 www) - 多域名证书漏掉了某个域名
避坑指南:我曾经遇到一个案例,证书 SAN 里写了 example.com 和 www.example.com,但用户访问的是 www.example.com.(注意末尾的点)。浏览器自动忽略了末尾的点,但 Python 的 ssl 库不会。这种边界情况,测试的时候一定要覆盖到。
4.4 证书验证流程图
下面这张图,把整个验证流程串起来了。你对照着看,思路会更清晰。
这张图从左到右,从上到下,把验证流程走了一遍。任何一个环节失败,都会导致验证不通过。
4.5 实战调试技巧
最后,分享几个我常用的调试命令。遇到证书问题,先用它们定位。
# 查看服务器发送的完整证书链
openssl s_client -connect www.example.com:443 -showcerts
# 只验证证书链,不建立连接
openssl s_client -connect www.example.com:443 -verify_return_error
# 查看证书的详细信息
openssl x509 -in server.crt -text -noout
# 检查 OCSP 状态(在线证书状态协议)
openssl ocsp -issuer intermediate.crt -cert server.crt -url http://ocsp.example.com
我的习惯:每次部署新证书,我都会先用 openssl s_client 手动验证一遍。确认没问题了,再更新到生产环境。这个习惯帮我避免了好几次线上事故。
证书验证,说白了就是信任的传递。你信根 CA,根 CA 信中间 CA,中间 CA 信服务器证书。只要链条不断,信任就在。
嗯,这一章的内容就到这。记住,验证不是可选项,是必选项。