深入记录协议:细节中的魔鬼

TLS 握手协商完密码套件之后,真正的数据传输就靠记录协议了。很多人觉得它不就是个封装吗?没那么简单。我记得刚接触 TLS 堆栈实现时,以为把数据包直接塞进加密通道就行,结果抓包一看,各种解析失败。原因就在我完全忽略了记录层的分段和 MAC 校验逻辑。

今天我们就来把记录协议从上到下扒一遍。说实话,这是 TLS 全栈里最容易被低估的一个环节。你想想看,它既要处理应用层传下来的任意长度数据,又要保证加密后不能被篡改,还得在握手阶段动态切换加密状态 —— 这就是个精细活。

4.1 记录层到底承载了什么

记录协议不是直接传输应用数据的。它位于握手协议、报警协议、Change Cipher Spec 协议的下方,给所有这些高层协议提供统一的传输服务。说白了,TLS 握手的每一次消息交换,最终都是通过记录协议封装后发出去的。

我见过不少开发者把记录层只当作“加密 + 传输”的简单封装,其实它做了四件事:

  • 分片 —— 把应用数据切成合适的大小块
  • 可选的压缩 —— 压缩后再加密(尽管现在基本不用)
  • 添加 MAC —— 完整性校验,防止传输中被篡改
  • 加密 —— 保证机密性

这四步顺序是严格固定的。不能乱,颠倒一步就废了。我曾经在定制化嵌入式 TLS 实现里,把加密和 MAC 的顺序搞反了一次,结果花了整整两天才定位到问题 — 所有数据都能正常加解密,但接收方永远报 bad_record_mac 错误。

一句话记住记录协议的本质:从应用层拿到的原始数据,经过分片 → 压缩 → 添加 MAC → 加密,然后才送入 TCP 通道。

4.2 记录格式:五个字节的头

每条 TLS 记录都有一个固定的 5 字节头部。嗯,这里很重要,抓包分析时你必须对这几个字节了然于胸。

字段 长度(字节) 说明
Content Type 1 标识上层协议类型:20=ChangeCipherSpec,21=Alert,22=Handshake,23=Application
Protocol Version 2 主版本号 + 次版本号。比如 TLS 1.2 是 0x0303
Length 2 后续载荷的字节长度(不包括头部本身),最大 2^14 字节

我习惯把这三个字段统称为记录头的“三板斧”。其中 Length 字段有个硬限制:不能超过 16384 字节(2^14)。为什么定这个值?我个人觉得是为了防止接收方一次性分配过大的内存缓冲区,降低拒绝服务攻击的风险。

但要注意,Length 只是记录层载荷的长度。如果应用层想发送 100KB 数据,单条记录装不下,就得靠分片处理了。

4.3 分片与重组:打散与拼接的艺术

应用层往下写数据时,记录协议不会直接原封不动地塞入记录体。它先判断数据长度:

  • 如果数据 ≤ 2^14 字节:直接封装成一条记录。
  • 如果数据 > 2^14 字节:必须拆成多个分片记录,每个不超过 2^14 字节。

为什么这么设计?一方面避免单条记录撑爆接收缓冲区,另一方面配合底层的 MTU 问题。我记得有一次调试一个移动设备上的 HTTPS 慢速问题,抓包发现很多 TLS 记录碎片化严重,每条才几百字节。后来我把应用层数据分批写入,让记录层的分片更整一些,性能一下上来了。

接收方怎么做重组?根据 Content Type 和序列号隐式关联。注意,记录协议本身不在头部标注分片的序列号,而是靠 TLS 连接状态中的序列号字段来区分先后顺序。这一点跟 TCP 分片的重组逻辑不太一样,得留个心。

避坑指南:我曾经在处理分片重组时,没有正确处理记录层的过期清理。当连接非常空闲时,某些分片一直没到,缓冲区就堆积了。这会导致内存泄漏。应该在收到空记录或超时后,主动清空未完成的重组缓冲区。

4.4 压缩:曾经的优化,现在的鸡肋

TLS 1.2 及之前版本支持在记录层做数据压缩。流程是:分片之后 → 压缩 → 添加 MAC → 加密。

压缩的好处显而易见:减少加密后传输的数据量。但为什么现在基本没人用?

  • 安全漏洞:CRIME 攻击利用 TLS 压缩中的明文长度变化推测秘密内容。说白了,敌人可以通过观察压缩后的包大小反推出加密前的数据模式。
  • 计算开销:压缩本身消耗 CPU,在移动设备上尤其不划算。
  • TLS 1.3 直接移除:2018 年发布的 TLS 1.3 干脆砍掉了压缩支持。

如果你的系统还跑在 TLS 1.2 且开启了压缩,我建议立刻关闭。这是一个典型的“为了提高一点点性能而牺牲安全”的决策,不值得。

4.5 MAC:完整性校验的最后防线

加密只能保证数据不被偷看,不能保证数据不被篡改。这就是 MAC(消息认证码)存在的原因。

TLS 1.2 使用 HMAC(基于哈希的消息认证码)。计算方式有些特别:它把序列号、记录头、明文数据和填充长度一起算进去。为什么要加序列号?为了防止重放攻击。你想想看,如果攻击者把之前的一条合法记录原封不动重放一遍,接收方拿它当新数据去处理,后果可能很严重。序列号的存在让每一条记录都是唯一的,重放的记录在 MAC 校验阶段就会被拒绝。

我记得调试过一个诡异的报错:某些数据包总是在对端解析时报 bad_record_mac。查了两小时发现是我们的加密实现里,MAC 计算时漏掉了 record header 中的 5 个字节。这个错误相当隐蔽,因为大部分数据走通,唯独某些边界条件下的几字节偏移导致了 MAC 验签失败。

注意:MAC 长度由密码套件决定。常用的 AES-GCM 模式下 MAC 由 GCM 认证标签提供,不再使用单独的 HMAC。在 AES-CBC 模式下,MAC 是单独的 HMAC。千万别把两种模式的 MAC 长度搞混,否则解析到一半就崩了。

4.6 加密与记录序列号

加密发生在 MAC 添加之后。对于 AES-CBC 模式:

加密内容 = 明文 + MAC + 填充(padding)

填充是必须的,因为块加密算法要求输入长度是块的整数倍。填充的格式有严格规范:填充内容 = 填充长度 - 1,且填充长度本身至少为1。举例来说,如果填充了 5 个字节,就用 0x04, 0x04, 0x04, 0x04, 0x04。为什么不是 0x05? 因为填充长度也算在填充字节里。

序列号我就不多展开了,只说一点:它是 64 位无符号整数,从 0 开始逐条递增,每条记录一个。当序列号达到 2^64 - 1 时,连接必须断开。我在生产环境中确实见过连接因为序列号溢出而中断的案例 — 虽然概率极低,但也提醒我们长连接的安全管理不能只看密钥。

好,下面这张图总结了记录协议的完整流程:

TLS 记录协议核心流程(发送方向) 应用层数据 (Application Data) 分片 (Fragment <= 2¹⁴) 压缩 (Compression,TLS 1.3 已移除) 添加 MAC (HMAC + 序列号) 加密 (Encrypt + 可选填充) 添加记录头 (ContentType + Version + Length) 送入 TCP 传输层 接收方:解密 → 验 MAC → 解压 → 重组

从图中可以清楚看到:发送时从应用数据开始,经过分片、压缩、MAC、加密、加头,最终进入 TCP。接收方则反向操作:先去掉记录头、解密、校验 MAC、解压、然后按照 Content Type 重组后交给上层。

4.7 关于记录层的一个小总结

记录协议是 TLS 中被忽视最多但出问题最多的地方。究其原因,是它对每一条记录都做了严格的格式、长度、顺序和校验控制。任何一个环节的偏移、遗漏或顺序颠倒都会导致握手失败或数据传输中断。

我给你三个实操建议:

  1. 开发阶段一定要开启抓包:用 Wireshark 检查记录层每个字段,跟规范对照。我通常会在握手和数据传输两个阶段都抓包,因为我发现很多 bug 只有在数据传输阶段才暴露。
  2. MAC 和加密的顺序不能错:先 MAC 后加密是标准流程。某些自研栈为了简化实现搞反了顺序,结果在互操作性测试中直接挂了。
  3. 分片不要超出 2^14 限制:这是硬性规定。即使你底层 TCP 缓冲区够大,TLS 规范也不允许单条记录载荷超过 16384 字节。超出部分会被对端丢弃或导致连接重置。
我个人的一个小技巧:在调试过程中,如果遇到 bad_record_mac 报错,优先检查序列号是否连续,然后再看 MAC 计算是否包含了 record header。我在工作中十次有七八次都是这两个原因导致的。

记录协议的设计核心是 拆分封装 + 分层校验。看似复杂,但每一步都有明确的安全考量。下节课我们会在此基础上深入握手协议,那是 TLS 安全架构真正精彩的章节。