跳到内容

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 \
  -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”。

如果需要明确验证 hostname,可用:

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

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

Python 标准库的 ssl.create_default_context() 会为 server authentication 加载 secure defaults 和 default CA certificates。下面这个 client 不硬编码 Linux CA path,也不会在失败时悄悄关闭 hostname checking。

python
#!/usr/bin/env python3
import socket
import ssl
import sys


def https_get(host: str, path: str = "/", port: int = 443) -> bytes:
    if not path.startswith("/"):
        raise ValueError("path must use origin-form and start with /")

    context = ssl.create_default_context()
    context.set_alpn_protocols(["http/1.1"])

    with socket.create_connection((host, port), timeout=5) as raw:
        raw.settimeout(5)
        with context.wrap_socket(raw, server_hostname=host) as tls:
            print("TLS:", tls.version())
            print("cipher:", tls.cipher())
            print("ALPN:", tls.selected_alpn_protocol())

            request = (
                f"GET {path} HTTP/1.1\r\n"
                f"Host: {host}\r\n"
                "User-Agent: atlas-tls-client/1\r\n"
                "Accept: */*\r\n"
                "Connection: close\r\n"
                "\r\n"
            ).encode("ascii")
            tls.sendall(request)

            chunks: list[bytes] = []
            while True:
                chunk = tls.recv(65536)
                if not chunk:
                    return b"".join(chunks)
                chunks.append(chunk)


def main() -> None:
    host = sys.argv[1] if len(sys.argv) > 1 else "example.com"
    try:
        response = https_get(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) as exc:
        print(f"connection failed: {exc}", file=sys.stderr)
        raise SystemExit(1) from exc

    head, _, _body = response.partition(b"\r\n\r\n")
    print(head.decode("iso-8859-1"))


if __name__ == "__main__":
    main()

保存为 tls_client.py 后运行:

bash
python3 tls_client.py example.com

server_hostname=host 同时支持 SNI 和 client-side service identity check。两者目的不同:SNI 帮 server 选 certificate,hostname verification 检查选出的 certificate 是否匹配 client 的期望身份。

这个程序只使用 HTTP/1.1,并且用 Connection: close 把 response body 的结束交给 connection close。它是 TLS 观察实验,不是通用 HTTP client:没处理 redirect、proxy、compression、connection reuse 或 streaming body size limit。Production code 应使用维护良好的 HTTP library。

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

如果驿道只在一座私有城邦中使用,client 可以明确信任该城邦的 private CA:

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

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

mTLS 还要求 client 出示 certificate:

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

mTLS 能 authentication 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 对比。不要假设每个 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