4. TLS 记录协议:记录层结构、分片与重组、加密与 MAC、填充与压缩

聊完了握手层的那些“虚头巴脑”的协商,咱们终于要碰一碰 TLS 里最实在的部分——记录协议。

说白了,TLS 记录协议就是负责“干活”的。握手层谈好了用什么密钥、什么算法,记录层就负责把应用层的数据(比如 HTTP 请求)拿过来,按照约定好的规则,切碎、压缩(可选)、加密、加 MAC,然后扔给 TCP 发出去。接收端再反过来做一遍:解密、校验、重组,最后交给上层应用。

我个人习惯把记录协议看作一个“加工流水线”。数据进来,经过几道工序,变成密文包出去。今天咱们就把这条流水线上的每个工位都走一遍。

核心要点: 记录协议是 TLS 的数据传输载体。它不关心数据是什么,只关心怎么安全地把数据从 A 点搬到 B 点。
TLS 记录协议处理流程 应用层数据 分片 压缩(可选) 加密 + MAC TLS 记录 TLS 记录 解密 + 校验 解压缩 重组 应用数据 发送端:分片 → 压缩 → 加密+MAC → 封装记录 接收端:解析记录 → 解密+校验 → 解压缩 → 重组

4.1 记录层结构:一个“信封”的自我修养

每个 TLS 记录,本质上就是一个结构体。它把数据包在一个“信封”里,信封上写着内容类型、版本号和长度。

你想想看,TCP 是流式协议,数据过来是一串字节流,没有边界。TLS 记录协议就是靠这个“信封”来划分边界的。接收端先读 5 个字节的头部,就知道这个记录有多长,然后精确地读取那么多字节。

记录头的结构很简单,就 5 个字节:

字段 长度 说明
Content Type 1 字节 内容类型。20=ChangeCipherSpec, 21=Alert, 22=Handshake, 23=Application Data
Protocol Version 2 字节 TLS 版本号。例如 TLS 1.2 是 0x0303
Length 2 字节 负载长度(不包括头部),最大 2^14 字节(16384)
小技巧: 我在调试 TLS 抓包时,经常先看 Content Type。如果看到 21(Alert),那基本就是握手出问题了。如果看到 23(Application Data),说明握手已经完成,正在传输业务数据。

4.2 分片与重组:大块数据怎么塞进小信封?

应用层可能一次发来 100KB 的数据。但 TLS 记录协议规定,单个记录的最大负载是 16KB(2^14 字节)。

为什么是 16KB?嗯,这里有个历史原因。早期互联网带宽小,太大的记录会占用缓冲区太久,影响交互性。而且万一丢包重传,小记录的成本更低。

所以发送端必须做分片:把大块数据切成多个 16KB 的片段,每个片段独立走完压缩、加密流程,封装成独立的 TLS 记录发出去。

接收端做重组:从 TCP 流中逐个读取 TLS 记录,解密后把负载按顺序拼接起来,还原成原始数据交给上层。

分片的逻辑其实很简单:

// 伪代码:TLS 分片逻辑
int fragment_data(const uint8_t *input, size_t input_len,
                  uint8_t *output, size_t *output_len) {
    size_t offset = 0;
    size_t frag_count = 0;

    while (offset < input_len) {
        size_t frag_size = input_len - offset;
        if (frag_size > MAX_RECORD_SIZE) {
            frag_size = MAX_RECORD_SIZE;  // 16KB
        }
        // 将 input[offset..offset+frag_size] 封装为一个 TLS 记录
        build_tls_record(output, input + offset, frag_size);
        offset += frag_size;
        output += TLS_HEADER_SIZE + frag_size;
        frag_count++;
    }
    return frag_count;
}
注意: 我曾经在一个项目中遇到过一个问题:嵌入式设备的 RAM 只有 32KB,但应用层一次发送了 64KB 的数据。分片逻辑没问题,但接收端的重组缓冲区只有 16KB,导致数据溢出。所以,重组缓冲区的大小必须能容纳最大分片数量 * 16KB,或者应用层自己控制发送大小。

4.3 加密与 MAC:先加锁,再贴封条

这是记录协议最核心的安全保障。TLS 1.2 及之前版本,使用的是 MAC-then-Encrypt 的顺序:先计算 MAC,再把明文和 MAC 一起加密。

为什么是这个顺序?说白了,先算 MAC 是为了保证完整性,再加密是为了保证机密性。接收端先解密,再校验 MAC,如果 MAC 不对,说明数据被篡改过。

但这里有个坑。MAC-then-Encrypt 在理论上存在一些安全隐患(比如 padding oracle 攻击)。所以 TLS 1.3 改成了 Encrypt-then-MAC,或者直接用 AEAD(Authenticated Encryption with Associated Data)算法,比如 AES-GCM 和 ChaCha20-Poly1305。

AEAD 的好处是,加密和认证一步到位,不需要单独算 MAC。我个人在嵌入式项目里,只要硬件支持 AES 加速,首选就是 AES-GCM。如果硬件不支持,我会用 ChaCha20-Poly1305,纯软件实现也很快。

模式 顺序 安全性 典型算法
MAC-then-Encrypt 先 MAC,再加密 有 padding oracle 风险 TLS 1.2 中的 AES-CBC + HMAC
Encrypt-then-MAC 先加密,再 MAC 更安全 TLS 1.2 扩展,TLS 1.3 可选
AEAD 同时加密和认证 最安全,最推荐 AES-GCM, ChaCha20-Poly1305
避坑指南: 我曾经在移植 mbedTLS 时,默认用了 AES-CBC + HMAC 模式。结果在压力测试下,解密偶尔会报错。查了半天,发现是填充校验的问题。后来换成 AES-GCM,问题直接消失。所以,新项目尽量用 AEAD 算法,省心又安全。

4.4 填充与压缩:为了对齐,也为了省带宽

填充主要出现在分组加密模式(如 CBC)中。分组加密要求明文长度是分组大小的整数倍(AES 是 16 字节)。如果明文不够,就需要填充。

TLS 1.2 的填充规则是:填充的每个字节的值等于填充长度。比如需要填充 5 个字节,那这 5 个字节都是 0x05。接收端解密后,看最后一个字节的值,就知道要删掉多少个填充字节。

嗯,这里要注意。填充本身不提供安全性,它只是为了满足加密算法的对齐要求。但填充长度可以被攻击者利用(padding oracle attack),所以 TLS 1.3 直接废弃了 CBC 模式,改用 AEAD,彻底绕过了填充问题。

压缩在 TLS 1.2 中是可选的。理论上,压缩可以减少传输数据量,节省带宽。但实际中,压缩带来的安全风险远大于收益。

为什么?因为压缩算法对重复内容敏感。攻击者可以通过观察压缩后的数据长度,推断出明文中的某些信息(CRIME 攻击、BREACH 攻击)。

所以,TLS 1.3 直接删除了压缩功能。我个人建议,无论你用哪个 TLS 版本,都关掉压缩。嵌入式设备的 CPU 资源本来就紧张,压缩省下的那点带宽,还不够弥补安全风险和性能开销。

经验之谈: 我在一个物联网项目中,设备通过 2G 网络上传传感器数据,带宽非常有限。一开始想用 TLS 压缩来省流量,但测试后发现,传感器数据本身随机性很强,压缩率几乎为 0,反而增加了 CPU 负载。后来我们直接在应用层做了差分编码,效果比 TLS 压缩好得多。

4.5 记录协议的整体处理流程

把上面这些串起来,一个 TLS 记录从生成到发送,大概走这几步:

  1. 接收应用数据:从上层拿到待发送的明文数据。
  2. 分片:如果数据超过 16KB,切成多个片段。
  3. 压缩(可选):对每个片段进行压缩。建议关掉。
  4. 添加 MAC:用当前连接的 MAC 密钥,计算压缩后数据的 MAC。
  5. 填充:如果是 CBC 模式,填充到分组大小整数倍。
  6. 加密:用当前连接的加密密钥,加密(压缩数据 + MAC + 填充)。
  7. 封装记录头:加上 5 字节的头部(类型、版本、长度)。
  8. 发送:通过 TCP 发送出去。

接收端反过来做:解析头部 → 解密 → 校验 MAC → 解压缩 → 重组 → 交给上层。

总结一下: 记录协议就是 TLS 的“搬运工”。它把数据切碎、打包、加密、贴封条,然后交给 TCP 运走。理解了这个流程,你就掌握了 TLS 数据传输的底层逻辑。后面讲 TLS 1.3 时,你会发现流程更简洁,但核心思想没变。