每次你看到浏览器地址栏的锁头图标,背后就是 TLS 在工作。TLS 1.3 是 2018 年发布的安全协议,它把延续了 20 年的 SSL/TLS 握手从 2-RTT 简化到了 1-RTT,同时移除了所有已知不安全的算法。
在 数字签名 中理解了签名与验证。这一章来拆解 TLS 1.3 的完整握手流程。
一、TLS 概述
1.1 TLS 的角色
TLS(Transport Layer Security)在传输层提供加密、认证和完整性保护:
| TLS 提供的安全属性 | 实现方式 |
|---|---|
| 机密性 | 对称加密(AES-GCM/ChaCha20) |
| 认证 | 数字证书(X.509) |
| 完整性 | AEAD 认证标签 |
| 前向安全 | ECDHE 临时密钥交换 |
1.2 TLS 在网络协议栈中的位置
TLS 工作在传输层(TCP)之上、应用层(HTTP)之下,这就是它叫”Transport Layer Security”的原因:
TLS 保护的是应用层数据的机密性和完整性,但不保护 IP 头、TCP 头等元数据——这意味着攻击者仍然能看到通信双方的 IP 地址、数据包大小和时序模式。
1.3 TLS 保护什么,不保护什么
| 维度 | TLS 保护 | TLS 不保护 |
|---|---|---|
| 数据内容 | 加密传输 | — |
| 数据完整性 | AEAD 认证标签 | — |
| 服务器身份 | X.509 证书验证 | — |
| 通信方 IP 地址 | — | 明文可见 |
| 数据包大小 | — | 可用于流量分析 |
| 访问时间 | — | 可用于时序分析 |
| DNS 查询 | — | 除非使用 DoH/DoT |
| SNI 信息 | — | 明文可见(TLS 1.3 用 ESNI 缓解) |
TLS 不是万能的。它保护数据在传输中不被窃听和篡改,但无法防御流量分析、元数据泄露等侧信道攻击。对于这些威胁,需要结合 Tor、VPN、ESNI 等额外机制。
1.4 TLS 版本演进
| 版本 | 年份 | 状态 | 关键改进 |
|---|---|---|---|
| SSL 3.0 | 1996 | 已废弃 | — |
| TLS 1.0 | 1999 | 已废弃 | — |
| TLS 1.1 | 2006 | 已废弃 | — |
| TLS 1.2 | 2008 | 仍可用 | AEAD 支持 |
| TLS 1.3 | 2018 | 推荐 | 简化握手、0-RTT、前向安全 |
1.5 TLS 1.2 的已知问题
TLS 1.2 的问题不是”缺少功能”,而是”允许太多不安全的选项”。以下是针对 TLS 1.2 的著名攻击:
| 攻击名称 | 年份 | 利用漏洞 | 影响 |
|---|---|---|---|
| BEAST | 2011 | CBC 模式 IV 可预测 | 解密 HTTPS 数据 |
| CRIME | 2012 | TLS 压缩侧信道 | 窃取会话 Cookie |
| POODLE | 2014 | SSL 3.0 CBC 填充 | 降级攻击解密数据 |
| ROBOT | 2017 | RSA PKCS#1 v1.5 填充预言机 | 伪造签名解密数据 |
| Logjam | 2015 | EXPORT 级 DH 参数降级 | 破解密钥交换 |
| Sweet32 | 2016 | 64 位分组密码碰撞 | 长连接数据泄露 |
| DROWN | 2016 | SSLv2 协议交叉攻击 | 解密 TLS 服务器数据 |
这些攻击的共同根源是:TLS 1.2 允许协商使用不安全的算法。TLS 1.3 的解决方式很直接——直接移除这些算法,而不是再打补丁。
二、TLS 1.3 握手流程
2.1 完整握手(1-RTT)
TLS 1.3 的核心改进是把握手从 2-RTT 压缩到 1-RTT。关键在于:客户端在第一条消息中就发送 DH 公钥,而不是等服务器先确认用哪个密钥交换算法。
| 步骤 | 消息 | 方向 | 说明 |
|---|---|---|---|
| 1 | ClientHello | C→S | 客户端发送支持的套件、DH 公钥和签名算法 |
| 2 | ServerHello | S→C | 服务端选定套件,发送 DH 公钥 |
| 3 | EncryptedExtensions | S→C | 服务端扩展信息(ALPN 等) |
| 4 | Certificate | S→C | 服务端发送证书链 |
| 5 | CertificateVerify | S→C | 服务端签名证明拥有私钥 |
| 6 | Finished | S→C | 服务端确认握手完成 |
| 7 | Finished | C→S | 客户端确认握手完成 |
注意:从 ServerHello 之后的所有消息都是加密的——这是 TLS 1.3 相比 1.2 的重要改进,TLS 1.2 中 Certificate 和 CertificateVerify 是明文传输的。
2.2 ClientHello 包含什么
ClientHello 是 TLS 握手的第一条消息,客户端用它告诉服务器”我支持什么”:
| 字段/扩展 | 内容 | 说明 |
|---|---|---|
| client_version | 0x0303 | 兼容字段,实际版本由 supported_versions 决定 |
| random | 32 字节随机数 | 防止重放,参与密钥派生 |
| session_id | 空或旧会话 ID | TLS 1.3 不依赖它,但保留兼容性 |
| cipher_suites | 5 个 TLS 1.3 套件 | 客户端支持的密码套件列表 |
| compression_methods | null | TLS 1.3 禁止压缩(防 CRIME 攻击) |
| supported_versions | [0x0304] | 表示支持 TLS 1.3 |
| key_share | X25519 公钥(32 字节) | 关键:提前发送 DH 公钥 |
| signature_algorithms | rsa_pss_rsae_sha256 等 | 支持的签名算法 |
| supported_groups | x25519, secp256r1 等 | 支持的椭圆曲线 |
| psk_key_exchange_modes | psk_dhe_ke | PSK 配合 DHE 的模式 |
| pre_shared_key | 可选,PSK 标识 | 用于会话恢复或 0-RTT |
2.3 ServerHello 包含什么
ServerHello 是服务端的回应,告诉客户端”我选了什么”:
| 字段/扩展 | 内容 | 说明 |
|---|---|---|
| server_version | 0x0303 | 兼容字段 |
| random | 32 字节随机数 | 服务端随机数,参与密钥派生 |
| session_id | 回显客户端的 ID | 兼容中间件 |
| cipher_suite | 选定的套件 | 如 TLS_AES_256_GCM_SHA384 |
| compression_method | null | 不压缩 |
| supported_versions | 0x0304 | 确认使用 TLS 1.3 |
| key_share | X25519 公钥 | 关键:服务端 DH 公钥 |
2.4 为什么 TLS 1.3 能做到 1-RTT
TLS 1.2 需要 2-RTT 的根本原因是:客户端必须先问服务器”你支持什么密钥交换”,服务器回答后,客户端才能发送自己的 DH 公钥。这多了一个来回。
TLS 1.3 的解决方案是猜测:
- 客户端在 ClientHello 中直接发送
key_share(DH 公钥),同时列出所有支持的曲线 - 如果服务器支持客户端选的曲线,直接回 ServerHello + 自己的 DH 公钥 → 1-RTT 完成
- 如果服务器不支持,回 HelloRetryRequest 让客户端换一条曲线 → 退化为 2-RTT
实际部署中,X25519 和 secp256r1 的覆盖率超过 99%,所以 HelloRetryRequest 极少发生。
key_shares = { "x25519": generate_x25519_keypair(), # 优先级最高 "secp256r1": generate_ec_keypair("P-256"), # 备选}2.5 ECDHE 密钥交换过程
TLS 1.3 强制使用 ECDHE(椭圆曲线 Diffie-Hellman 临时密钥交换),以 X25519 为首选曲线。以下是密钥交换的数学过程:
客户端 服务器------ ------生成私钥 a(随机标量) 生成私钥 b(随机标量)计算公钥 A = a * G 计算公钥 B = b * G (G 是曲线基点)
发送 A ──────────────────────────────────→ ←────────────────────── 发送 B
计算共享密钥: 计算共享密钥:shared = a * B shared = b * A = a * (b * G) = b * (a * G) = (a * b) * G = (a * b) * G ↑ 两者相等 ↑
前向安全保证:- a 和 b 是临时生成的,用完即弃- 即使长期私钥泄露,也无法反推 shared- 每次连接的 shared 都不同X25519 基于 Curve25519 椭圆曲线,密钥只有 32 字节,计算速度比 NIST P-256 快约 3 倍,且实现更简单、更抗侧信道攻击。
三、TLS 1.2 vs 1.3 对比
3.1 握手流程对比
3.2 核心差异对比
| 维度 | TLS 1.2 | TLS 1.3 |
|---|---|---|
| 握手 RTT | 2-RTT | 1-RTT |
| 密码套件 | 37 个 | 5 个 |
| 密钥交换 | RSA/ECDHE | 仅 ECDHE |
| 前向安全 | 可选 | 强制 |
| 0-RTT | 不支持 | 支持 |
| 已知弱点 | BEAST/CRIME/ROBOT | 无已知弱点 |
| 证书加密 | 明文传输 | 加密传输 |
| 协商方式 | 逐项协商 | 一次性确定 |
| 扩展机制 | 有限 | 丰富的扩展框架 |
3.3 TLS 1.3 移除了什么
TLS 1.3 的设计哲学是”做减法”——移除一切不安全的选项:
| 移除项 | 原因 | 替代方案 |
|---|---|---|
| RSA 密钥交换 | 无前向安全,私钥泄露=历史全泄露 | ECDHE(强制前向安全) |
| CBC 模式 | BEAST/POODLE/Lucky13 攻击 | AEAD(GCM/ChaCha20) |
| RC4 | 多种密码学攻击,已完全不安全 | AES-GCM/ChaCha20 |
| SHA-1 | 碰撞攻击已实际可行 | SHA-256/SHA-384 |
| MD5 | 碰撞攻击极其容易 | SHA-256/SHA-384 |
| DES/3DES | 密钥太短/Sweet32 攻击 | AES-128/AES-256 |
| EXPORT 密码套件 | Logjam 降级攻击 | 不再支持弱密码 |
| 压缩 | CRIME/BREACH 攻击 | 禁用 TLS 层压缩 |
| 非AEAD 密码套件 | 无法同时保证加密+完整性 | 仅保留 AEAD |
| renegotiation | 降级攻击风险 | 不支持重新协商 |
| DSA 签名 | 随机数泄露=私钥泄露 | RSA-PSS/ECDSA/EdDSA |
TLS 1.3 移除了所有不安全的算法:RSA 密钥交换、CBC 模式、RC4、SHA-1、MD5。这是 TLS 1.3 比 TLS 1.2 更安全的根本原因——不是新增了什么,而是移除了不安全的东西。
3.4 迁移挑战
从 TLS 1.2 迁移到 TLS 1.3 并非总是平滑的:
| 挑战 | 说明 | 解决方案 |
|---|---|---|
| 中间件兼容性 | 部分防火墙/代理不识别 TLS 1.3 | 逐步灰度,保留 1.2 兼容 |
| 证书兼容性 | 某些旧证书签名算法不被 1.3 支持 | 更新证书,使用 RSA-PSS 或 ECDSA |
| 应用层依赖 | 代码依赖 TLS 1.2 的 renegotiation | 重构应用逻辑,避免 renegotiation |
| 调试困难 | 1.3 加密了更多握手信息 | 使用 SSLKEYLOGFILE 配合 Wireshark |
| 合规要求 | 某些行业标准尚未更新到 1.3 | 保留 1.2 兼容,同时启用 1.3 |
四、0-RTT(早数据)
4.1 0-RTT 原理
0-RTT 允许客户端在重连时,在握手的第一条消息中就携带加密的应用数据,无需等待服务器响应:
0-RTT 的工作前提是客户端拥有上次连接获得的 PSK(Pre-Shared Key)。客户端用 PSK 派生出早数据密钥,在 ClientHello 中直接携带加密数据。
4.2 0-RTT 重放攻击
0-RTT 最大的安全风险是重放攻击。因为 0-RTT 数据不包含服务端的随机数贡献,攻击者可以捕获并重放:
攻击场景:0-RTT 重放攻击
1. 客户端发送 0-RTT 请求: PUT /api/transfer HTTP/1.1 Authorization: Bearer token_xxx { "to": "attacker", "amount": 1000 }
2. 攻击者截获该请求
3. 攻击者原封不动地重放(可能多次): → 服务器收到相同的 PUT 请求 → 服务器处理了多次转账 → 客户端被转走 N × 1000 元
关键问题:- 0-RTT 数据没有 Server Random 参与- 服务器无法区分"新请求"和"重放请求"- TCP 层的重放防御对应用层无效4.3 如何安全使用 0-RTT
| 安全措施 | 说明 | 实现方式 |
|---|---|---|
| 仅用于幂等操作 | GET/HEAD 请求天然幂等 | 应用层限制 0-RTT 请求类型 |
| 服务端反重放 | 记录已处理的 0-RTT 请求 | 使用时间窗口 + 去重缓存 |
| 限制 0-RTT 数据量 | 减少重放的影响面 | max_early_data_size 控制大小 |
| 单次使用票据 | 每个 PSK 只用一次 | 服务器签发一次性 Session Ticket |
| 时间窗口限制 | 票据过期后不可用 | Session Ticket 设置短有效期 |
4.4 哪些场景不能用 0-RTT
| 场景 | 原因 | 正确做法 |
|---|---|---|
| 支付/转账 | 非幂等,重放=重复扣款 | 使用 1-RTT |
| 创建订单 | 非幂等,重放=重复下单 | 使用 1-RTT |
| 修改密码 | 非幂等,重放=安全隐患 | 使用 1-RTT |
| 删除资源 | 非幂等,重放=误删 | 使用 1-RTT |
| POST/PUT 请求 | 通常非幂等 | 默认不用 0-RTT |
| 跨服务器负载均衡 | 不同服务器可能无法共享反重放状态 | 使用 1-RTT 或共享反重放缓存 |
0-RTT 数据容易受到重放攻击——攻击者可以捕获并重放 0-RTT 请求。只对幂等操作(GET 请求、查询)使用 0-RTT,不要用于支付、下单等非幂等操作。
五、密钥派生
5.1 完整密钥调度
TLS 1.3 使用 HKDF 从共享密钥逐层派生出所有需要的密钥。整个过程形成一个严格的层次结构:
5.2 HKDF-Extract 和 HKDF-Expand-Label
TLS 1.3 的密钥派生基于两个核心操作:
HKDF-Extract:从输入密钥材料中提取固定长度的伪随机密钥
HKDF-Expand-Label:从伪随机密钥派生出指定长度的密钥
HKDF-Expand-Label(Secret, Label, Context, Length) = HKDF-Expand(Secret, HkdfLabel, Length)
其中 HkdfLabel 的结构:struct { uint16 length = Length; opaque label<7..255> = "tls13 " + Label; opaque context<0..255> = Context;} HkdfLabel;
示例:派生客户端握手密钥HKDF-Expand-Label( handshake_secret, "c hs traffic", // label Transcript-Hash(CH), // context = ClientHello 的哈希 32 // length) → client_handshake_traffic_secret5.3 从 ECDHE 共享密钥到应用数据密钥
完整的密钥派生链路如下:
import hmacimport hashlibdef hkdf_extract(salt: bytes, ikm: bytes) -> bytes: """HKDF-Extract: HMAC(salt, IKM)""" return hmac.new(salt, ikm, hashlib.sha256).digest()def hkdf_expand_label(secret: bytes, label: str, context: bytes, length: int) -> bytes: """HKDF-Expand-Label(简化版)""" hkdf_label = length.to_bytes(2, 'big') label_bytes = b"tls13 " + label.encode() hkdf_label += len(label_bytes).to_bytes(1, 'big') + label_bytes hkdf_label += len(context).to_bytes(1, 'big') + context return hkdf_expand(secret, hkdf_label, length)def hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes: """HKDF-Expand""" n = (length + 31) // 32 okm = b"" t = b"" for i in range(1, n + 1): t = hmac.new(prk, t + info + i.to_bytes(1, 'big'), hashlib.sha256).digest() okm += t return okm[:length]shared_secret = ecdhe_compute_key(client_private, server_public)early_secret = hkdf_extract(b'\x00' * 32, b'\x00' * 32)handshake_secret = hkdf_extract(early_secret, shared_secret)client_hs_traffic = hkdf_expand_label(handshake_secret, "c hs traffic", transcript_hash_ch, 32)server_hs_traffic = hkdf_expand_label(handshake_secret, "s hs traffic", transcript_hash_ch, 32)client_hs_key = hkdf_expand_label(client_hs_traffic, "key", b"", 16) # AES-128client_hs_iv = hkdf_expand_label(client_hs_traffic, "iv", b"", 12) # GCM IVserver_hs_key = hkdf_expand_label(server_hs_traffic, "key", b"", 16)server_hs_iv = hkdf_expand_label(server_hs_traffic, "iv", b"", 12)master_secret = hkdf_extract(handshake_secret, b'\x00' * 32)client_app_traffic = hkdf_expand_label(master_secret, "c ap traffic", transcript_hash_all, 32)server_app_traffic = hkdf_expand_label(master_secret, "s ap traffic", transcript_hash_all, 32)client_app_key = hkdf_expand_label(client_app_traffic, "key", b"", 16)client_app_iv = hkdf_expand_label(client_app_traffic, "iv", b"", 12)server_app_key = hkdf_expand_label(server_app_traffic, "key", b"", 16)server_app_iv = hkdf_expand_label(server_app_traffic, "iv", b"", 12)5.4 派生密钥一览
| 派生密钥 | 用途 | 何时使用 |
|---|---|---|
| client_handshake_key/iv | 加密客户端握手消息 | Finished 消息 |
| server_handshake_key/iv | 加密服务端握手消息 | EncryptedExtensions 开始 |
| client_application_key/iv | 加密客户端应用数据 | 握手完成后 |
| server_application_key/iv | 加密服务端应用数据 | 握手完成后 |
| exporter_master_secret | 密钥导出(RFC 5705) | 外部协议绑定 |
| resumption_master_secret | 派生 PSK 用于会话恢复 | 会话恢复 |
六、TLS 握手抓包分析
6.1 Wireshark 抓取 TLS 1.3 握手
用 Wireshark 分析真实的 TLS 1.3 握手,是理解协议最直观的方式:
export SSLKEYLOGFILE=/tmp/sslkeys.log
sudo tshark -i eth0 -f "tcp port 443" -w /tmp/tls13-capture.pcap &
curl -v --tls13-ciphers TLS_AES_256_GCM_SHA384 https://example.com
tshark -r /tmp/tls13-capture.pcap -Y "tls.handshake" -T fields \ -e frame.number \ -e ip.src \ -e ip.dst \ -e tls.handshake.type \ -e tls.handshake.version6.2 TLS 1.3 握手报文解读
以下是典型的 TLS 1.3 握手报文序列:
| 帧号 | 源 → 目的 | TLS 消息类型 | 值 | 说明 |
|---|---|---|---|---|
| 1 | Client → Server | ClientHello | 1 | 包含 key_share、supported_versions |
| 2 | Server → Client | ServerHello | 2 | 选定套件,发送 key_share |
| 3 | Server → Client | ChangeCipherSpec | — | 兼容中间件,无实际意义 |
| 4 | Server → Client | EncryptedExtensions | 8 | 加密扩展(ALPN 等) |
| 5 | Server → Client | Certificate | 11 | 服务器证书链 |
| 6 | Server → Client | CertificateVerify | 15 | 签名验证 |
| 7 | Server → Client | Finished | 20 | 握手完成 |
| 8 | Client → Server | ChangeCipherSpec | — | 兼容中间件 |
| 9 | Client → Server | Finished | 20 | 客户端确认 |
帧号 3 和 8 中的 ChangeCipherSpec 是 TLS 1.3 为了兼容中间件(middlebox)而保留的”假”消息,在 TLS 1.3 中没有任何实际安全作用。如果中间件看不到这个消息,可能会误判连接异常。
6.3 如何用 Wireshark 解密 TLS 流量
Wireshark 配合 SSLKEYLOGFILE 可以解密 TLS 1.3 的加密握手消息:
- 设置环境变量:
export SSLKEYLOGFILE=/tmp/sslkeys.log - 启动浏览器或应用:Chrome/Firefox/curl 都支持此环境变量
- 配置 Wireshark:Edit → Preferences → Protocols → TLS → “(Pre)-Master-Secret log filename” → 选择
/tmp/sslkeys.log - 抓包并分析:现在 Wireshark 能解密所有 TLS 消息
head -3 /tmp/sslkeys.logSSLKEYLOGFILE 仅用于开发调试!生产环境绝对不能启用,否则等于把所有 TLS 密钥明文写在磁盘上。密钥日志文件包含所有连接的会话密钥,任何能读取该文件的人都能解密你的 TLS 流量。
七、TLS 会话恢复
7.1 Session Ticket 机制
TLS 1.3 不再使用 TLS 1.2 的 Session ID 恢复机制,而是采用 Session Ticket:
Session Ticket 的核心思路是:服务器把 PSK 用只有自己知道的密钥加密后发给客户端,客户端下次连接时带上这个”票据”,服务器解密后即可恢复会话,无需再次进行证书验证。
| 特性 | Session ID(TLS 1.2) | Session Ticket(TLS 1.3) |
|---|---|---|
| 状态存储 | 服务端保存会话状态 | 客户端保存加密票据 |
| 服务端开销 | 需要维护会话缓存 | 无状态,解密即可 |
| 负载均衡友好 | 需要共享会话缓存 | 天然支持,票据自包含 |
| 安全性 | 会话 ID 明文传输 | PSK 加密传输 |
| 票据生命周期 | 服务端控制 | 票据中包含 lifetime |
7.2 Pre-Shared Key(PSK)模式
TLS 1.3 支持两种 PSK 建立方式:
| PSK 来源 | 说明 | 使用场景 |
|---|---|---|
| Session Ticket | 上次连接后服务器签发 | 最常见,自动会话恢复 |
| 外部配置 | 预共享密钥(如 IoT 设备) | 受限环境,无证书基础设施 |
PSK 模式的安全属性:
| 属性 | PSK-only | PSK + DHE(推荐) |
|---|---|---|
| 前向安全 | 无 | 有 |
| 密钥交换 | 仅 PSK | PSK + ECDHE |
| 0-RTT 支持 | ||
| 安全等级 | 较低 | 高 |
PSK_KE_ONLY = 0 # 仅 PSK,无前向安全(不推荐)PSK_DHE_KE = 1 # PSK + DHE,有前向安全(推荐)
psk_key_exchange_modes = [PSK_DHE_KE] # 只接受 PSK + DHE八、TLS 1.3 密码套件
| 套件 | 加密 | 密钥交换 | 签名 |
|---|---|---|---|
| TLS_AES_128_GCM_SHA256 | AES-128-GCM | ECDHE | RSA/ECDSA |
| TLS_AES_256_GCM_SHA384 | AES-256-GCM | ECDHE | RSA/ECDSA |
| TLS_CHACHA20_POLY1305_SHA256 | ChaCha20-Poly1305 | ECDHE | RSA/ECDSA |
| TLS_AES_128_CCM_SHA256 | AES-128-CCM | ECDHE | RSA/ECDSA |
| TLS_AES_128_CCM_8_SHA256 | AES-128-CCM-8 | ECDHE | RSA/ECDSA |
密码套件命名规则:TLS_{加密算法}_{哈希算法}。注意 TLS 1.3 的密码套件不再包含密钥交换和签名算法——因为密钥交换固定为 ECDHE,签名算法在签名扩展中单独协商。
九、配置最佳实践
9.1 Nginx 配置
server { listen 443 ssl http2; server_name example.com; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256; ssl_prefer_server_ciphers on; ssl_certificate /etc/ssl/certs/server.crt; ssl_certificate_key /etc/ssl/private/server.key; ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate /etc/ssl/certs/chain.pem; resolver 8.8.8.8 8.8.4.4 valid=300s; ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets on; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always;}9.2 Apache 配置
<VirtualHost *:443> ServerName example.com
SSLEngine on SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256
SSLCipherSuite SSL ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384
SSLCertificateFile /etc/ssl/certs/server.crt SSLCertificateKeyFile /etc/ssl/private/server.key SSLCertificateChainFile /etc/ssl/certs/chain.pem
# HSTS Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
# OCSP Stapling SSLUseStapling on SSLStaplingCache shmcb:/var/run/ocsp(128000)</VirtualHost>9.3 Go 配置
package mainimport ( "crypto/tls" "net/http")func main() { // TLS 1.3 配置 tlsConfig := &tls.Config{ MinVersion: tls.VersionTLS12, CurvePreferences: []tls.CurveID{ tls.X25519, // 优先 X25519 tls.CurveP256, // 备选 P-256 }, CipherSuites: []uint16{ tls.TLS_CHACHA20_POLY1305_SHA256, tls.TLS_AES_256_GCM_SHA384, tls.TLS_AES_128_GCM_SHA256, }, } server := &http.Server{ Addr: ":443", TLSConfig: tlsConfig, } // Go 1.21+ 默认启用 TLS 1.3 server.ListenAndServeTLS("server.crt", "server.key")}9.4 Java 配置
// Java: TLS 1.3 配置(JDK 11+)import javax.net.ssl.SSLContext;import java.net.ServerSocket;import java.net.Socket;public class TLSServer { public static void main(String[] args) throws Exception { // 启用 TLS 1.3 SSLContext sslContext = SSLContext.getInstance("TLSv1.3"); sslContext.init(null, null, null); // 创建 SSL ServerSocket ServerSocket serverSocket = sslContext .getServerSocketFactory() .createServerSocket(443); // JDK 11+ 默认支持 TLS 1.3 // 可通过系统属性控制: // jdk.tls.client.protocols=TLSv1.3 // jdk.tls.server.protocols=TLSv1.3 while (true) { Socket client = serverSocket.accept(); // 处理连接... } }}9.5 SSL Labs A+ 评级清单
要获得 SSL Labs A+ 评级,需要满足以下所有条件:
| 检查项 | 要求 | 验证方式 |
|---|---|---|
| TLS 版本 | ≥ 1.2,推荐 1.3 | ssl_protocols TLSv1.2 TLSv1.3 |
| 密码套件 | 仅 AEAD(GCM/ChaCha20) | ssl_ciphers 不含 CBC/RC4 |
| 证书 | RSA ≥ 2048 或 ECC ≥ 256 | openssl x509 -text |
| HSTS | 启用,max-age ≥ 1 年 | add_header Strict-Transport-Security |
| 证书透明度 | 启用 CT 日志 | 证书包含 SCT 扩展 |
| OCSP Stapling | 启用 | ssl_stapling on |
| 前向安全 | 强制 ECDHE | 密码套件含 ECDHE |
| 无已知漏洞 | 无 BEAST/ROBOT/Heartbleed | sslyze 扫描 |
9.6 常见配置错误
| 错误 | 后果 | 正确做法 |
|---|---|---|
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 | 支持 POODLE/BEAST 攻击 | 只保留 TLSv1.2 TLSv1.3 |
ssl_ciphers 包含 RC4/DES | 数据可被破解 | 只用 AEAD 套件 |
| HSTS max-age 太短 | 浏览器很快忘记 | ≥ 31536000(1 年) |
| 证书私钥权限 644 | 任何用户可读私钥 | 权限设为 600 |
| 未启用 OCSP Stapling | 客户端需单独查询 OCSP | ssl_stapling on |
| SSL Session Timeout 太长 | 会话劫持风险增大 | 1 天左右 |
| 同时支持 RSA 密钥交换 | 无前向安全 | 只用 ECDHE 套件 |
九·附、实践:用 s_client 分析 TLS 握手
SSL/TLS 协议的历史几乎与万维网本身一样长。1995 年,Netscape 发布了 SSL 2.0——这是第一个被广泛部署的安全传输协议,但它在设计上存在严重缺陷:没有消息认证、密钥交换可被中间人攻击、截断攻击无法检测。SSL 3.0(1996)修复了大部分问题,却引入了 CBC 填充预言机漏洞——这一隐患在 18 年后被 POODLE 攻击彻底引爆。
1999 年,IETF 接管了 SSL 的标准化工作,将其更名为 TLS 1.0。但 TLS 1.0 本质上只是 SSL 3.0 的微调版本,CBC 模式的 IV 可预测性问题依然存在,这直接导致了 2011 年的 BEAST 攻击。TLS 1.1(2006)修复了 CBC 的 IV 问题,却未能阻止更广泛的攻击浪潮——CRIME(2012)利用 TLS 压缩侧信道窃取 Cookie,ROBOT(2017)利用 RSA PKCS#1 v1.5 填充预言机伪造签名,Logjam(2015)通过 EXPORT 级 DH 参数降级攻击破解密钥交换。这些攻击的共同根源是:协议允许协商使用不安全的算法选项。
TLS 1.3(RFC 8446,2018)的解决方式不再是打补丁,而是做减法——直接移除 RSA 密钥交换、CBC 模式、RC4、SHA-1、MD5、压缩、 renegotiation 等所有已知不安全的选项,将密码套件从 37 个精简到 5 个,同时把握手从 2-RTT 压缩到 1-RTT。这一章,用 OpenSSL 的 s_client 工具和 Python ssl 模块,亲手验证这些理论改进。
附.1 前置知识
- OpenSSL 3.0+ 命令行工具(本文使用 OpenSSL 3.0.13)
- Python 3.8+ 及
ssl模块(本文使用 Python 3.13.0) - 理解本章前九节的理论内容,特别是握手流程、密码套件和密钥交换
注:如果你的网络环境需要代理才能访问外部网站,
s_client可以使用-proxy参数指定 HTTP 代理,例如-proxy 127.0.0.1:7890。
附.2 TLS 1.3 握手分析
用 openssl s_client 连接 GitHub,强制使用 TLS 1.3:
openssl s_client -connect github.com:443 -servername github.com -tls1_3 </dev/null关键输出如下:
CONNECTED(00000003)---Certificate chain 0 s:CN = github.com i:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication CA DV E36 a:PKEY: id-ecPublicKey, 256 (bit); sigalg: ecdsa-with-SHA256 v:NotBefore: May 5 00:00:00 2026 GMT; NotAfter: Aug 2 23:59:59 2026 GMT 1 s:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication CA DV E36 i:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication Root E46 a:PKEY: id-ecPublicKey, 256 (bit); sigalg: ecdsa-with-SHA384 v:NotBefore: Mar 22 00:00:00 2021 GMT; NotAfter: Mar 21 23:59:59 2036 GMT 2 s:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication Root E46 i:C = US, ST = New Jersey, L = Jersey City, O = The USERTRUST Network, CN = USERTrust ECC Certification Authority a:PKEY: id-ecPublicKey, 384 (bit); sigalg: ecdsa-with-SHA384 v:NotBefore: Mar 22 00:00:00 2021 GMT; NotAfter: Jan 18 23:59:59 2038 GMT---...Peer signing digest: SHA256Peer signature type: ECDSAServer Temp Key: X25519, 253 bits---SSL handshake has read 3126 bytes and written 373 bytesVerification: OK---New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256Server public key is 256 bitSecure Renegotiation IS NOT supportedCompression: NONEExpansion: NONENo ALPN negotiatedEarly data was not sentVerify return code: 0 (ok)---DONE逐行解读:
| 输出字段 | 值 | 含义 |
|---|---|---|
New, TLSv1.3 | TLSv1.3 | 协议版本确认为 TLS 1.3 |
Cipher is TLS_AES_128_GCM_SHA256 | AES-128-GCM + SHA-256 | TLS 1.3 密码套件,命名规则为 TLS_{加密}_{哈希} |
Server Temp Key: X25519 | X25519 | 临时密钥交换使用 Curve25519,保证前向安全 |
Peer signature type: ECDSA | ECDSA | 证书签名算法为 ECDSA(非 RSA) |
Secure Renegotiation IS NOT supported | 不支持 | TLS 1.3 移除了 renegotiation 机制 |
Early data was not sent | 未发送 | 非 0-RTT 连接,没有早数据 |
Certificate chain 深度 3 | 3 级 | 叶子证书 → 中间 CA → 根 CA |
注:TLS 1.3 密码套件的命名不再包含密钥交换和签名算法——因为密钥交换固定为 ECDHE,签名算法在
signature_algorithms扩展中单独协商。这与 TLS 1.2 的ECDHE-ECDSA-AES128-GCM-SHA256命名形成鲜明对比。
附.3 TLS 1.2 vs 1.3 对比
用 -tls1_2 参数强制降级到 TLS 1.2:
openssl s_client -connect github.com:443 -servername github.com -tls1_2 </dev/null关键输出:
...Peer signing digest: SHA256Peer signature type: ECDSAServer Temp Key: X25519, 253 bits---SSL handshake has read 3030 bytes and written 365 bytesVerification: OK---New, TLSv1.2, Cipher is ECDHE-ECDSA-AES128-GCM-SHA256Server public key is 256 bitSecure Renegotiation IS supportedCompression: NONEExpansion: NONENo ALPN negotiatedSSL-Session: Protocol : TLSv1.2 Cipher : ECDHE-ECDSA-AES128-GCM-SHA256 Session-ID: Session-ID-ctx: Master-Key: F23AA9C594D9E89CDDCE5EC174C31A9F... PSK identity: None PSK identity hint: None SRP username: None Start Time: 1778164040 Timeout : 7200 (sec) Verify return code: 0 (ok) Extended master secret: yes---DONE对比两张握手的差异:
| 对比维度 | TLS 1.3 | TLS 1.2 |
|---|---|---|
| 协议版本 | TLSv1.3 | TLSv1.2 |
| 密码套件 | TLS_AES_128_GCM_SHA256 | ECDHE-ECDSA-AES128-GCM-SHA256 |
| 套件命名 | TLS_{加密}_{哈希} | {密钥交换}-{签名}-{加密}_{模式}-{哈希} |
| Secure Renegotiation | IS NOT supported | IS supported |
| Master-Key 字段 | 不暴露 | 明文暴露 Master-Key |
| Session-ID | 不使用 | 保留(为空) |
| Early data | was not sent | 无此字段 |
| 握手字节数(读/写) | 3126 / 373 | 3030 / 365 |
关键发现:
- 密码套件命名变化:TLS 1.3 的
TLS_AES_128_GCM_SHA256不再包含密钥交换和签名算法,因为它们已经固定/单独协商 - Master-Key 不再暴露:TLS 1.2 的输出中
Master-Key明文可见,而 TLS 1.3 的输出中完全没有这个字段——这本身就是安全改进 - Renegotiation 被移除:TLS 1.3 不支持安全重新协商,因为该机制本身是攻击面
- 握手数据量相近:虽然 TLS 1.3 减少了一个 RTT,但由于加密了更多握手消息,总字节数反而略多
注意:TLS 1.2 输出中的
Master-Key仅在调试时可见,不会在网络上传输。但这也意味着任何能访问s_client输出的人都能获取会话密钥——生产环境中应避免记录此类信息。
附.4 证书链与 OCSP Stapling
3.1 查看完整证书链
openssl s_client -connect github.com:443 -servername github.com -showcerts </dev/null输出中的证书链结构:
Certificate chain 0 s:CN = github.com i:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication CA DV E36 a:PKEY: id-ecPublicKey, 256 (bit); sigalg: ecdsa-with-SHA256 v:NotBefore: May 5 00:00:00 2026 GMT; NotAfter: Aug 2 23:59:59 2026 GMT-----BEGIN CERTIFICATE-----MIID7jCCA5SgAwIBAgIRAOfOzDsT+zt7ikbqjNCutxwwCgYIKoZIzj0EAwIw...-----END CERTIFICATE----- 1 s:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication CA DV E36 i:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication Root E46 a:PKEY: id-ecPublicKey, 256 (bit); sigalg: ecdsa-with-SHA384 v:NotBefore: Mar 22 00:00:00 2021 GMT; NotAfter: Mar 21 23:59:59 2036 GMT-----BEGIN CERTIFICATE-----MIIDXzCCAuagAwIBAgIQNuBZ7YiN1Xrt1XC2cn+b2jAKBggqhkjOPQQDAzBf...-----END CERTIFICATE----- 2 s:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication Root E46 i:C = US, ST = New Jersey, L = Jersey City, O = The USERTRUST Network, CN = USERTrust ECC Certification Authority a:PKEY: id-ecPublicKey, 384 (bit); sigalg: ecdsa-with-SHA384 v:NotBefore: Mar 22 00:00:00 2021 GMT; NotAfter: Jan 18 23:59:59 2038 GMT-----BEGIN CERTIFICATE-----MIIDRjCCAsugAwIBAgIQGp6v7G3o4ZtcGTFBto2Q3TAKBggqhkjOPQQDAzCBi...-----END CERTIFICATE-----证书链解读:
| 层级 | 角色 | 主题 (Subject) | 签名算法 | 公钥类型 |
|---|---|---|---|---|
| 0 | 叶子证书 | CN=github.com | ecdsa-with-SHA256 | EC 256 bit |
| 1 | 中间 CA | CN=Sectigo Public Server Authentication CA DV E36 | ecdsa-with-SHA384 | EC 256 bit |
| 2 | 根 CA | CN=Sectigo Public Server Authentication Root E46 | ecdsa-with-SHA384 | EC 384 bit |
注:GitHub 的证书链全部使用 ECDSA 签名,没有 RSA 证书。这是现代 TLS 部署的趋势——ECDSA 证书更短(256 bit vs 2048 bit)、签名更快、握手数据量更小。
3.2 检查 OCSP Stapling
openssl s_client -connect github.com:443 -servername github.com -status </dev/null输出:
CONNECTED(00000003)OCSP response: no response sent---Certificate chain 0 s:CN = github.com i:C = GB, O = Sectigo Limited, CN = Sectigo Public Server Authentication CA DV E36...OCSP response: no response sent 表示服务器没有启用 OCSP Stapling。这意味着客户端需要自行向 OCSP 响应器查询证书吊销状态,会增加一个额外的网络请求。
注:OCSP Stapling 是服务器在 TLS 握手时主动附带 OCSP 响应的机制。启用后,客户端无需单独查询 OCSP 服务器,既减少延迟又保护隐私。在生产环境中,建议始终启用 OCSP Stapling(参考第九章的 Nginx/Apache 配置)。
虽然 GitHub 未启用 OCSP Stapling,但证书中包含了 OCSP 响应器的 URL:
# 提取证书中的 OCSP URIopenssl s_client -connect github.com:443 -servername github.com </dev/null 2>/dev/null | \ openssl x509 -noout -ocsp_urihttp://ocsp.sectigo.com附.5 Python ssl 模块实践
OpenSSL 命令行适合快速诊断,但编程接口更灵活。Python 的 ssl 模块封装了 OpenSSL,可以直接获取 TLS 连接的结构化信息。
4.1 TLS 1.3 连接
import ssl, socket
# 创建 TLS 1.3 上下文ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)ctx.check_hostname = Truectx.verify_mode = ssl.CERT_REQUIREDctx.load_default_certs()
# 连接 github.com:443with socket.create_connection(("github.com", 443)) as sock: with ctx.wrap_socket(sock, server_hostname="github.com") as ssock: print(f"协议版本: {ssock.version()}") print(f"密码套件: {ssock.cipher()[0]}") print(f"密钥位数: {ssock.cipher()[2]}") cert = ssock.getpeercert() print(f"主题: {dict(x[0] for x in cert['subject'])}") print(f"签发者: {dict(x[0] for x in cert['issuer'])}") print(f"SAN: {[x[1] for x in cert['subjectAltName']]}") print(f"有效期: {cert['notBefore']} ~ {cert['notAfter']}") print(f"压缩: {ssock.compression()}")运行结果:
协议版本: TLSv1.3密码套件: TLS_AES_128_GCM_SHA256密钥位数: 128主题: {'commonName': 'github.com'}签发者: {'countryName': 'GB', 'organizationName': 'Sectigo Limited', 'commonName': 'Sectigo Public Server Authentication CA DV E36'}SAN: ['github.com', 'www.github.com']有效期: May 5 00:00:00 2026 GMT ~ Aug 2 23:59:59 2026 GMT压缩: None4.2 对比 TLS 1.2 连接
# 创建 TLS 1.2 上下文ctx12 = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)ctx12.maximum_version = ssl.TLSVersion.TLSv1_2ctx12.check_hostname = Truectx12.verify_mode = ssl.CERT_REQUIREDctx12.load_default_certs()
with socket.create_connection(("github.com", 443)) as sock: with ctx12.wrap_socket(sock, server_hostname="github.com") as ssock: print(f"协议版本: {ssock.version()}") print(f"密码套件: {ssock.cipher()[0]}") print(f"压缩: {ssock.compression()}")运行结果:
协议版本: TLSv1.2密码套件: ECDHE-ECDSA-AES128-GCM-SHA256压缩: None注:Python
ssl模块通过ctx.maximum_version控制协议版本上限,通过ctx.minimum_version控制下限。ssl.TLSVersion.TLSv1_3和ssl.TLSVersion.TLSv1_2是 Python 3.7+ 引入的枚举值。
附.6 实践小结
| 对比维度 | TLS 1.3 | TLS 1.2 |
|---|---|---|
| 握手 RTT | 1-RTT | 2-RTT |
| 密码套件数量 | 5 个(仅 AEAD) | 37 个(含不安全选项) |
| 密码套件命名 | TLS_AES_128_GCM_SHA256 | ECDHE-ECDSA-AES128-GCM-SHA256 |
| 密钥交换 | 仅 ECDHE(X25519) | RSA 或 ECDHE |
| 前向安全 | 强制 | 可选 |
| Renegotiation | 不支持 | 支持 |
| Master-Key 暴露 | 不暴露 | s_client 输出中可见 |
| 证书传输 | 加密 | 明文 |
| OCSP Stapling | 支持(status_request 扩展) | 支持 |
| 0-RTT | 支持 | 不支持 |
| 压缩 | 禁止 | 允许(但有 CRIME 风险) |
通过 s_client 和 Python ssl 模块的实际操作,验证了 TLS 1.3 的核心改进:更少的密码套件、更简洁的命名、强制前向安全、移除 renegotiation、不暴露 Master-Key。这些不是纸面上的规范差异,而是每一次 s_client 连接都能观察到的真实变化。
十、总结
上一章深入探讨了数字签名与证书链。
10.1 核心要点
| 维度 | 关键要点 |
|---|---|
| 握手 | 1-RTT,ECDHE 强制前向安全 |
| 0-RTT | 重连时零延迟,但有重放风险 |
| 密码套件 | 仅 5 个,全部 AEAD |
| 密钥派生 | HKDF,从共享密钥逐层派生所有密钥 |
| 安全改进 | 移除 RSA 密钥交换、CBC、SHA-1 |
| 配置 | TLS 1.3 + HSTS + OCSP Stapling |
| 会话恢复 | Session Ticket + PSK + DHE |
| 抓包分析 | SSLKEYLOGFILE + Wireshark |
10.2 TLS 版本迁移清单
从 TLS 1.2 迁移到 TLS 1.3 的步骤:
| 步骤 | 操作 | 验证方式 |
|---|---|---|
| 1 | 审计现有 TLS 配置 | sslyze 或 testssl.sh 扫描 |
| 2 | 更新服务器软件到支持 TLS 1.3 的版本 | Nginx ≥ 1.13.0, OpenSSL ≥ 1.1.1 |
| 3 | 配置 ssl_protocols TLSv1.2 TLSv1.3 | 保留 1.2 兼容 |
| 4 | 配置 TLS 1.3 密码套件 | 仅 AEAD 套件 |
| 5 | 更新证书签名算法 | 使用 RSA-PSS 或 ECDSA |
| 6 | 启用 HSTS 和 OCSP Stapling | 添加安全头 |
| 7 | 灰度测试 | 先在测试环境验证 |
| 8 | SSL Labs 扫描 | 确认 A+ 评级 |
| 9 | 监控错误率 | 确认客户端兼容性 |
| 10 | 逐步禁用 TLS 1.2 | 根据业务需求决定时间 |
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






