bigywater.tech
网络协议 · 94

双向 TLS:客户端证书认证

常见的 HTTPS 只验证服务器;双向 TLS(mTLS)让客户端也出示证书,服务器据此知道“你是谁”,在服务之间的通信和零信任网络里很常用。这篇在本机用脚本生成 CA 和几张客户端证书,把握手的成功和各种失败、线上的可见性以及额外的代价逐个量一遍。

0.72 → 0.87 ms
本机 TLS 1.3 单向与双向握手耗时中位数
假成功
TLS 1.3 里没带客户端证书时,客户端握手返回成功,第一次读才收到 certificate required 警报
明文 vs 加密
TLS 1.2 里客户端证书在线上明文可见,TLS 1.3 里藏在加密记录里

小 PKI

实测 · 本机执行输出
  CA:CN=w94 Test CA,CA=True;服务器证书:CN=localhost,扩展 serverAuth,SAN localhost;客户端证书 alice:extendedKeyUsage = clientAuth,签发者 w94 Test CA
  mallory 的证书由 "CN=w94 Other CA" 签发(服务器不信任这个 CA);bob 的证书有效期 10-05 ~ 10-07,已经过期

CA、服务器证书、有效 / 不被信任 / 已过期的客户端证书,全是临时生成的。Python 3.13 起对缺少 AKI / SKI 的证书严格拒绝。

REQUIRED

实测 · 本机执行输出
  alice(有效的客户端证书)             → 客户端:握手 1.7 ms 成功,收到 'hello alice@w94';服务器记录:('ok', 'alice@w94')
  不带任何客户端证书                   → 客户端:握手 1.1 ms 看起来成功,但第一次读就失败:SSLError [SSL: TLSV13_ALERT_CERTIFICATE_REQUIRED] tlsv13 alert certificate requ;服务器记录:('fail', 'SSLError: [SSL: PEER_DID_NOT_RETURN_A_CERTIFICATE] peer did not return')
  mallory(由不被信任的 CA 签发)       → 客户端:握手 1.1 ms 看起来成功,但第一次读就失败:SSLError [SSL: TLSV1_ALERT_UNKNOWN_CA] tlsv1 alert unknown ca (_ssl.c:2713);服务器记录:('fail', 'SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: ')
  bob(证书已过期)                  → 客户端:握手 1.1 ms 看起来成功,但第一次读就失败:SSLError [SSL: SSLV3_ALERT_CERTIFICATE_EXPIRED] ssl/tls alert certificate expir;服务器记录:('fail', 'SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: ')

有效证书成功;没证书、不被信任、已过期在 TLS 1.3 里都是第一次读才失败,错误码分别是 certificate required / unknown ca / certificate expired。

OPTIONAL

实测 · 本机执行输出
  alice     → 客户端:握手 1.0 ms 成功,收到 'hello alice@w94';服务器记录:('ok', 'alice@w94')
  不带证书      → 客户端:握手 1.2 ms 成功,收到 'hello anonymous';服务器记录:('ok', None)
  mallory   → 客户端:握手 1.1 ms 看起来成功,但第一次读就失败:SSLError [SSL: TLSV1_ALERT_UNKNOWN_CA] tlsv1 alert unknown ca (_ssl.c:2713);服务器记录:('fail', 'SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: ')

不出示可以进(身份 None);出示无效证书仍然失败。

隐私

实测 · 本机执行输出
  TLS 1.2:客户端→服务器 1232 字节:['ClientHello', 'Certificate', 'ClientKeyExchange', 'CertificateVerify', 'ChangeCipherSpec', 190, '(加密记录 26 字节)'];服务器→客户端 1824 字节:['ServerHello', 'Certificate', 'ServerKeyExchange', 'CertificateRequest', 'ServerHelloDone', 'NewSessionTicket', 'ChangeCipherSpec', 201, '(加密记录 39 字节)']
  TLS 1.3:客户端→服务器 2583 字节:['ClientHello', 'ChangeCipherSpec', '(加密记录 860 字节)', '(加密记录 95 字节)', '(加密记录 69 字节)', '(加密记录 19 字节)'];服务器→客户端 3800 字节:['ServerHello', 'ChangeCipherSpec', '(加密记录 23 字节)', '(加密记录 70 字节)', '(加密记录 884 字节)', '(加密记录 97 字节)', '(加密记录 69 字节)', '(加密记录 682 字节)', '(加密记录 682 字节)', '(加密记录 32 字节)']
  TLS 1.2 里服务器的 CertificateRequest 和客户端的 Certificate / CertificateVerify 都是明文握手消息,路径上的设备能直接读出客户端证书(包括里面的身份);TLS 1.3 把它们放进了加密记录里

TLS 1.2 里客户端证书明文传输,TLS 1.3 里加密。

代价

实测 · 本机执行输出
  单向 TLS(不要客户端证书)           :握手耗时 中位 0.72 ms,p90 0.78 ms
  双向 TLS(客户端出示证书)           :握手耗时 中位 0.87 ms,p90 0.99 ms
  双向认证比单向多约 0.15 ms(21%):客户端要多发一个证书 + 一个 CertificateVerify 签名,服务器要多验证一条证书链和一个签名(本机 lo0 上没有网络延迟,这个差就是纯计算和消息处理)

双向比单向每次握手多约 0.15 ms(回环上)。

分步演示

双向 TLS
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。全部在本机 lo0:服务器和客户端都是 Python ssl,证书是用 cryptography 临时生成的(使用后删除),不涉及任何真实身份或外部服务;第五节只比较了回环上的握手计算,没有网络延迟;抓包只列握手消息类型,没有解密
    • 没有验证:证书吊销(CRL / OCSP)对客户端证书的检查;证书链多级的 mTLS;客户端证书与 HTTP 认证头的配合;硬件密钥或智能卡里的客户端私钥;服务网格(Envoy、Istio)里的 mTLS 证书轮换;CertificateRequest 里 certificate_authorities 列表的内容
    京ICP备2026067502号-1京公网安备11010502063487号