4. TLS 记录协议:记录层结构、分片与重组、加密与 MAC、填充与压缩
聊完了握手层的那些“虚头巴脑”的协商,咱们终于要碰一碰 TLS 里最实在的部分——记录协议。
说白了,TLS 记录协议就是负责“干活”的。握手层谈好了用什么密钥、什么算法,记录层就负责把应用层的数据(比如 HTTP 请求)拿过来,按照约定好的规则,切碎、压缩(可选)、加密、加 MAC,然后扔给 TCP 发出去。接收端再反过来做一遍:解密、校验、重组,最后交给上层应用。
我个人习惯把记录协议看作一个“加工流水线”。数据进来,经过几道工序,变成密文包出去。今天咱们就把这条流水线上的每个工位都走一遍。
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) |
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;
}
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 |
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 资源本来就紧张,压缩省下的那点带宽,还不够弥补安全风险和性能开销。
4.5 记录协议的整体处理流程
把上面这些串起来,一个 TLS 记录从生成到发送,大概走这几步:
- 接收应用数据:从上层拿到待发送的明文数据。
- 分片:如果数据超过 16KB,切成多个片段。
- 压缩(可选):对每个片段进行压缩。建议关掉。
- 添加 MAC:用当前连接的 MAC 密钥,计算压缩后数据的 MAC。
- 填充:如果是 CBC 模式,填充到分组大小整数倍。
- 加密:用当前连接的加密密钥,加密(压缩数据 + MAC + 填充)。
- 封装记录头:加上 5 字节的头部(类型、版本、长度)。
- 发送:通过 TCP 发送出去。
接收端反过来做:解析头部 → 解密 → 校验 MAC → 解压缩 → 重组 → 交给上层。