15 的握手证实客户端连到了预期的 TLS 服务端并建立共享秘密。接下来的合成订单不是把证书公钥放在每一条 HTTP 请求前,重新签一遍;两端改用各自方向的应用流量密钥,给一条条 TLS 记录做认证加密。为什么记录还需要序号?如果攻击者复制刚才发送的那一条加密记录,再发一次,接收端能否分辨它不是下一条?TLS 记录的复制重放与有权客户端重新提交一笔相同订单是两个不同问题。

本篇实际在 Python/OpenSSL 的 TLS 1.3 MemoryBIO 端点之间截取一份应用数据的受保护记录,分别试正常接收、对端回发相同明文、重复投递同一加密记录以及翻转一位加密记录。它是成熟 TLS 库在内存里的真实协议组件实验;并没有提取/公开会话秘密、手写 TLS 记录格式或自行实现 AES-GCM。

从一份秘密派生多条路

10 的密钥交换得到共同输入,11 的 HKDF 说明可以按上下文派生分用途输出。TLS 1.3 按其自己的派生树取得客户端方向与服务器方向的应用流量秘密,进一步计算各方向的记录密钥和静态 IV。即便两个方向传送一模一样的订单字节,参与认证加密的密钥、记录序号以及上下文也不是“同一条记录”可以互换。客户端不能因为看到服务端证书里有一把公钥,就拿那把公钥当订单正文的直接加密密钥。

1
2
3
4
5
6
7
8
9
10
11
12
已认证握手与共享秘密
|
v
TLS 派生树(与握手摘要绑定)
| |
v v
client_application_traffic server_application_traffic
| |
client key/IV server key/IV
| |
客户端发的记录 → ← 服务端发的记录
每条还使用该方向的隐式序号、认证的记录信息与标签

图是关系而不是现成的秘密内容。证书、服务私钥、握手流量密钥、应用流量密钥各有不同职责;共享一个名字“TLS 密钥”会导致把证书吊销和会话更新当成一件事。实际 RFC 9846 的 key schedule 还区分早期、握手、应用等阶段,不把 11 的自定义 lab: 标签误称为 TLS 真实标签。

看不到的序号怎样防止复制记录

RFC 9846 §5.3 指出每个方向的记录序号是隐式状态:发送/接收两端按顺序推进,使用静态 IV 与填充后的序号异或得到用于所选 AEAD 的逐记录 nonce。它不是应用 JSON 里的 order_id,也不需要从外层 HTTP 头里读取。记录层在认证加密时还按规范纳入相应的附加认证数据与长度、头部等约定。改动密文字节或者把原样的上一条记录当下一条投递,即使载荷内容在上一步有过有效标签,此刻的 nonce/序号条件已经变了,接收端不能把它再交付成新的应用字节。

1
2
3
4
5
客户端发第 0 条:key_client + nonce(IV_client, seq=0) + 明文 -> 记录 0
客户端发第 1 条:key_client + nonce(IV_client, seq=1) + 明文 -> 记录 1
↑ 不同序号,校验条件不同

攻击者再送一次原封不动的记录 0:接收端期望下一条的状态

如果合法客户端主动构造一条新的记录,把与前次相同的订单字节再次加密并发送,新的序号、nonce 与标签都可以合法。TLS 不负责判断这是否第二次扣款或第二次订单。应用得检查已认证身份、订单唯一值、有效期和原子消费状态;链上交易另按 nonce 或未花费条件防止旧授权再次支出。删掉 TLS 记录校验,是“同一条线路密文可被悄悄改动或重新投递”的风险;删掉应用消费检查,是“全新的合法 TLS 记录载着重复订单仍可重复处理”的风险。

实验到底观察到什么

examples/cryptography/16_records.py 在自动清理的目录内用一次性 CA 给本地 orders.test 签发证书,客户端指定 CA 与主机名验证。Python ssl.SSLObject 与四个 MemoryBIO 对象让客户端、服务端通过进程内缓冲区完成真正的 TLS 1.3 握手。客户端加密合成订单 order=demo-001;quantity=1,脚本读取底层已加密的记录但不保存密文字节,交给服务端解密,确认确实收到原订单。

服务端用它自己的方向密钥回送相同的公开明文字节,客户端也能恢复,底层两个方向的记录却不相同。然后脚本复制前一份客户端加密记录,作为该连接的下一份输入重送,预期服务端拒绝;另在独立的 TLS 连接里翻转一条尚未交付的加密记录的最后一位,预期服务端拒绝。两个失败场景分开建连,避免第一次认证失败终止连接后再把后续操作的异常混淆为第二个独立负例。

1
2
python3 examples/cryptography/16_records.py
python3 -m unittest discover -s examples/cryptography -p 'test_16*.py' -v

2026-10-06 UTC,在 Python 3.12.3 / OpenSSL 3.0.13 上,两条命令退出码均为 0,1 项真实组件测试通过。输出显示 TLSv1.3、协商套件 TLS_AES_256_GCM_SHA384,外层应用记录类型字节为 23;底层记录不含原订单明文字节,双方接收到了预期订单内容,但两方向记录并不相等。同一密文再送被拒,改变记录一位也被拒。ssl.SSLError 说明该库拒绝这两条记录;实验没有在运行时读取内部序号/IV、重算标签或从密文证明一个具体的数学式,后者仍以 RFC 9846 §5 为依据。

这和 07 的“直接调 AES-GCM 原语并改 AAD/标签”是两个层次:07 的 nonce 和 AAD 是调用者直接提供的,16 的记录序号和 KeyUpdate 管理由 TLS 库和规范负责。正确算法输出也不保证代理之后的后端连接自动受保护:一旦 TLS 在反向代理终止,代理后那一段需要重新划定信任与安全责任,18 再分别检查。

KeyUpdate 更新什么,什么仍未验证

RFC 9846 §4.7.3/§7.2 规定应用期可更新某一方向的流量秘密,随之计算新的密钥与 IV、重置对应的记录计数,并在序号或密钥使用极限之前按规范更新或结束连接。它不等于换服务器证书,不等于替钱包换私钥,也不会帮业务消除旧订单。客户端和服务端是按方向分别更新,不能把一方已经切换的新密钥误当另一方向也自动同步。

本机 MemoryBIO 实验没有实际触发 KeyUpdate、长连接密钥限额或多代理转发,所以这些是研究规范后可说明的规则,不能登记为本章实测通过。实验也没验证浏览器证书吊销、实际性能或 0-RTT 防重放:17 需要单独讨论恢复握手和早期数据允许重放的不同性质。

两道带答案的练习

画图题。 客户端发送一条订单,服务端回送同样的字节。画出两个方向各自的密钥、IV 和序号位置。若客户端合法重发同一订单(不是网络攻击者复制旧记录),哪一层仍需阻止第二次业务效果?

可核对答案: 客户端→服务端和服务端→客户端各有独立的 traffic secret、key/IV 和自己的记录序号;同字节不代表同密文。合法客户端用下一序号生成新记录依然可被 TLS 接受,应由应用按已认证身份和订单唯一值做原子去重。复制旧记录则是记录层的重放拒绝问题。

变更题。 从 16_records.py 删去 changed_record[-1] ^= 1,再执行脚本和单测。哪个断言应该失败?能否据此推断 AES-GCM 对任何长度、任何次数、任何网络拓扑都有同样安全边界?

可核对答案: 第二个独立连接里没有改密文,服务端正常恢复订单,changed_record_rejected 应为假,脚本的拒绝断言和单元测试失败。这只说明指定 TLS 库、套件和一份订单的失败对照成立;要谈 nonce 分配、密钥使用上限、KeyUpdate 或代理跨段,需要规范与对应实验,不能从一次测试外推。

资料与导航