跳到内容

第 4 卷 / 第 5 章 / 第 2 课

5.2 TLS 安全客户端与故障诊断 ​

text
ssl.SSLCertVerificationError: certificate verify failed

重试不会自动修复它,关掉 verification 更不是修复。你需要先判断故障停在哪一层,再查该层的证据。

1. 先给故障画边界 ​

text
name resolution → TCP connect → TLS handshake → HTTP exchange
现象先查不要先猜
Name or service not knownDNS name 与 resolvercertificate
Connection refusedaddress、port、listenercipher suite
TCP timeoutroute、firewall、service loadHTTP status
CERTIFICATE_VERIFY_FAILEDtrust path、name、time、usageREST route
TLS 成功后收到 421/404SNI、Host、virtual host 与 HTTP routingCA store

这张表的目的不是一眼猜中 root cause,而是别让一个 application error 把你拖回去改 TLS configuration。

2. 用 OpenSSL 看见 handshake ​

检查 virtual host 时,-servername 不是装饰,它会发 SNI:

bash
openssl s_client \
  -connect example.com:443 \
  -servername example.com \
  -verify_hostname example.com \
  -alpn http/1.1 \
  -showcerts \
  -verify_return_error </dev/null

从 output 里分别找这些证据:

  • negotiated TLS version 和 cipher suite;
  • leaf 的 Subject / Subject Alternative Name;
  • server 实际发送了哪些 intermediate certificate;
  • Verify return code;
  • ALPN 是否选中预期 protocol。

-showcerts 显示 server 发来的 certificate list,它不等于 OpenSSL 已经为你证明每一张 certificate 都可信。结论要看 verification result,并且 hostname verification 要按你使用的 OpenSSL command/version 明确开启,不要只看“handshake succeeded”。

上面的命令已开启名称验证。若要隔离 DNS,同时保持预期身份,可以把连接地址换成你有权测试的实际 IP,保留 -servername 与 -verify_hostname;下面的 192.0.2.10 是文档占位地址,运行前替换:

bash
openssl s_client \
  -connect 192.0.2.10:443 \
  -servername example.com \
  -verify_hostname example.com \
  -alpn http/1.1 \
  -verify_return_error </dev/null

3. 一个默认验证身份的 Python client ​

下面使用 Python 3.12 和兼容的 OpenSSL。ssl.create_default_context() 加载默认信任库;指定 cafile 时使用该文件的信任锚。程序要求 TLS 1.2+,关闭旧 CN fallback,并为 TLS 握手及其后收发设总预算和响应大小上限。

python
#!/usr/bin/env python3
import argparse
import ipaddress
import re
import socket
import ssl
import sys
import time

MAX_RESPONSE = 2 * 1024 * 1024
TARGET = re.compile(r"/(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[0-9A-Fa-f]{2})*")

def https_get(host, path="/", port=443, *, cafile=None, connect_host=None):
    if not TARGET.fullmatch(path):
        raise ValueError("path must be an ASCII origin-form URI without fragment")
    if not 1 <= port <= 65535:
        raise ValueError("port must be between 1 and 65535")
    try:
        address = ipaddress.ip_address(host)
        identity = str(address)
        authority = f"[{identity}]" if address.version == 6 else identity
    except ValueError:
        # Accept DNS A-label names; convert international names before calling.
        if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", host):
            raise ValueError("host must be an IP literal or ASCII DNS name")
        identity = host
        authority = host
    if port != 443:
        authority += f":{port}"

    context = ssl.create_default_context(cafile=cafile)
    context.minimum_version = ssl.TLSVersion.TLSv1_2
    context.hostname_checks_common_name = False
    context.set_alpn_protocols(["http/1.1"])

    with socket.create_connection((connect_host or identity, port), timeout=5) as raw:
        deadline = time.monotonic() + 10

        def remaining():
            seconds = deadline - time.monotonic()
            if seconds <= 0:
                raise TimeoutError("TLS exchange deadline exceeded")
            return seconds

        raw.settimeout(remaining())
        with context.wrap_socket(
            raw, server_hostname=identity, suppress_ragged_eofs=False,
        ) as tls:
            selected = tls.selected_alpn_protocol()
            if selected not in (None, "http/1.1"):
                raise ssl.SSLError("unexpected ALPN protocol")
            print("TLS:", tls.version())
            print("cipher:", tls.cipher())
            print("ALPN:", selected)

            request = (
                f"GET {path} HTTP/1.1\r\n"
                f"Host: {authority}\r\n"
                "User-Agent: atlas-tls-client/1\r\n"
                "Accept: */*\r\n"
                "Connection: close\r\n"
                "\r\n"
            ).encode("ascii")
            tls.settimeout(remaining())
            tls.sendall(request)
            data = bytearray()
            while True:
                tls.settimeout(remaining())
                chunk = tls.recv(min(65536, MAX_RESPONSE + 1 - len(data)))
                if not chunk:
                    tls.settimeout(remaining())
                    tls.unwrap().close()
                    return bytes(data)
                data.extend(chunk)
                if len(data) > MAX_RESPONSE:
                    raise ValueError("raw HTTP response exceeds 2 MiB")

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("host", nargs="?", default="example.com")
    parser.add_argument("--port", type=int, default=443)
    parser.add_argument("--cafile")
    parser.add_argument("--connect-host")
    args = parser.parse_args()
    try:
        response = https_get(
            args.host, port=args.port, cafile=args.cafile,
            connect_host=args.connect_host,
        )
    except ssl.SSLCertVerificationError as exc:
        print(f"certificate verification failed: {exc.verify_message}", file=sys.stderr)
        raise SystemExit(2) from exc
    except (ssl.SSLError, OSError, ValueError) as exc:
        print(f"exchange failed: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    head, marker, _body = response.partition(b"\r\n\r\n")
    if not marker:
        raise SystemExit("incomplete HTTP header section")
    print(head.decode("iso-8859-1"))

if __name__ == "__main__":
    main()

保存为 tls_client.py 后运行:

bash
python3 tls_client.py example.com

DNS 名称下,server_hostname 用于 SNI 和名称验证;IP literal 则按 IP SAN 验证,通常不发送 DNS SNI。connect_host 只改变网络连接地址,预期身份与 HTTP Host 仍来自 host,非默认端口会包含在 Host 中。输入需使用 ASCII DNS A-label,URI 非 ASCII 内容先正确 percent-encode。

这里保存最多 2 MiB 的原始 HTTP 响应,TLS 握手、请求发送和读到关闭共用 TCP 连接成功后的 10 秒预算;DNS 和各地址连接尝试不在这个预算内。Connection: close 请求服务端关闭连接,不会覆盖 Content-Length 或 chunked 的 framing 规则。本程序没有解析这些规则,也不处理 redirect、proxy、compression 或复用,不能据 TLS 正常关闭就宣布 HTTP body 完整。

suppress_ragged_eofs=False 让没有 close_notify 的 TCP EOF 报错,而不是伪装成正常 TLS 结束。默认信任库也不等于自动在线查询所有撤销状态:Python ssl 的 CRL/OCSP 能力与配置需单独核对。实际 HTTP 客户端应复用成熟库,同时保持这些身份与资源边界。

4. 不要用这些“修复” ​

python
# 不要放进 production client
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE

这会同时拿掉 trust path 与 service identity 保护。Traffic 仍然可能显示为 encrypted,但 client 不知道 key 是和真 server 还是 attacker 协商的。

更常见的正确修复是:

  • 补齐 server 的 intermediate chain;
  • 换用覆盖正确 DNS name 的 certificate;
  • 修正 system clock;
  • 把 private CA 作为明确 trust anchor 配置给指定 client;
  • 修复 proxy 或 TLS interception 的 enterprise trust configuration;
  • 记录并完成 certificate renewal,而不是临时忽略 expiry。

5. Private CA 和 mTLS ​

内部服务可以为这个客户端明确配置 private CA:

python
context = ssl.create_default_context(cafile="company-root-ca.pem")

这与 CERT_NONE 完全不同:前者改变了 trust anchor 集合,仍然执行 certificate 和 hostname verification;后者取消验证。

显式 cafile 并不是默认系统根证书的自动追加。若策略要求保留系统根再增加 private CA,可先 create_default_context(),再调用 load_verify_locations(cafile=...);不要修改机器的全局信任库来完成局部实验。

mTLS 还要求 client 出示 certificate:

python
context = ssl.create_default_context(cafile="company-root-ca.pem")
context.load_cert_chain("client-cert.pem", "client-key.pem")

仅加载 client certificate 不会让服务器自动认证它。服务器还必须请求证书并验证相应信任链;Python server 可结合 CERT_REQUIRED 与明确的客户端 CA 配置实现强制认证。

mTLS 能认证 client certificate identity,却不自动完成 application authorization。Server 仍要把 certificate identity 映射到明确 principal,再判断这个 principal 能否执行当前 operation。Private key 也要有 access control 和 rotation policy。

6. 从错误走到 root cause ​

Certificate expired ​

确认当前时间、leaf validity period 和 renewal job。不要只换 leaf,还要检查 server 引用的 full chain 文件是否真正 reload。

Unable to get local issuer certificate ​

先确认客户端信任库是否包含预期根,再把 server 发送的 list 与所需 intermediate 对比。缺 trust anchor 和缺 intermediate 都可能触发这个错误;不要假设每个 client 都会从网上自动补链。

Hostname mismatch ​

检查实际 URL name、SNI 和 leaf subjectAltName。直接把 URL 换成 IP 常会让 virtual hosting 和 identity verification 同时失效。

Protocol version / no shared cipher ​

先确认 client 与 server 的 supported versions 和 policy,再检查 cipher/group/signature 交集。不要为了一台旧 client 盲目全局恢复 TLS 1.0/1.1 或 weak algorithms;优先 upgrade / isolate 该 client,并以当前 TLS guidance 评估兼容边界。

7. 练习与验收 ​

  1. 用 openssl s_client 观察一个你有权测试的 host,记录 TLS version、cipher、ALPN 和 verification result。
  2. 在本地将 Python client 的 host 换成与 certificate 不匹配的 name,观察 verify_message;不要关闭 verification。
  3. 用一句话分别解释 SNI、hostname verification 和 HTTP Host。
  4. 为 internal service 设计 private CA 配置,写出 trust anchor 放在哪里、谁能更新、如何 rotation。
  5. 解释为什么 mTLS authentication 成功后仍要 authorization。

验收标准不是背出一串 cipher 名,而是能用证据说清故障停在 DNS、TCP、TLS 还是 HTTP,且不会用“关掉 verification”来清除报错。

当证书链能连到受信 trust anchor,且服务身份与目标名称匹配时,客户端才应继续使用这条 protected channel。需要持久双向消息时,继续阅读:WebSocket 与 gRPC。

参考 ​

Built with VitePress | Software Systems Atlas