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 证书验证流程图

下面这张图,把整个验证流程串起来了。你对照着看,思路会更清晰。

TLS 证书验证流程图 TLS 证书验证流程 开始验证 检查证书有效期 失败:证书过期 验证证书签名 失败:签名无效/自签名 验证信任链 失败:链不完整/根不受信任 验证域名匹配 失败:域名不匹配 验证成功 ✅ 验证失败常见原因 1. 证书过期 2. 自签名证书 3. 证书链不完整 4. 域名不匹配 调试建议 使用 openssl s_client 查看完整证书链 检查系统时间

这张图从左到右,从上到下,把验证流程走了一遍。任何一个环节失败,都会导致验证不通过。

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 信服务器证书。只要链条不断,信任就在。

嗯,这一章的内容就到这。记住,验证不是可选项,是必选项。