## TLS记录层协议详解归纳总结 ### 记录层协议的核心定位 聊 TLS 之前,很多朋友先关注握手层——证书、密钥交换、密码套件……这没错。但我个人习惯,先从记录层下手。你想想看,所有 TLS 数据,无论是握手消息、报警、还是应用数据,最终都得装进记录层这个容器里。记录层,就是 TLS 的“传输骨干”。 我还在第一次排查 TLS 异常时,抓包看到一堆“Bad Record MAC”错误,愣是没反应过来——原来是记录层校验没通过。嗯,从那以后,我只要遇到 TLS 连接异常,第一件事就是看记录层 header。 ### 记录层的数据结构 每条 TLS 记录,都是这么几个字段拼起来的。来,直接看结构:
struct {
    ContentType type;       // 1字节,内容类型
    ProtocolVersion version; // 2字节,TLS版本号
    uint16 length;           // 2字节,负载长度
    opaque fragment[TLSPlaintext.length]; // 负载数据
} TLSPlaintext;
#### 关键字段速查 | 字段 | 长度 | 说明 | 常见值 | |------|------|------|--------| | ContentType | 1 字节 | 内容类型 | 20=ChangeCipherSpec, 21=Alert, 22=Handshake, 23=Application Data | | ProtocolVersion | 2 字节 | 协议版本 | 0x0301=TLS1.0, 0x0303=TLS1.2, 0x0304=TLS1.3 | | Length | 2 字节 | 负载长度 | 0~16384(TLS1.2 及以下不能超过 2^14 字节) | | fragment | 可变 | 实际数据 | 分片后可能小于 16384 | 这个表你最好记牢。我在分析证书验证失败日志时,经常抓包看见 version 字段写的是 0x0300(SSL3.0),服务端直接给你丢一个 protocol_version 报警。所以看一眼 version 基本就知道是哪一方的锅了。 ### 记录层的分片与重组 TLS 记录层会把上层数据切成长度不超过 2^14 字节的片段。为什么?早期的 SSL 设计考虑到了网络 MTU 限制,避免 IP 层分片。不过你会发现,实际协商时很多实现会协商最大片段长度(max_fragment_length extension),甚至切到 512 字节。 分片规则很简单: - 所有上层消息(握手、报警等)都有可能被分片,除了 ChangeCipherSpec。 - 接收端根据记录头部的 length 字段重组完整消息。 - 如果收到长度超过 2^14 的记录,直接丢一个 record_overflow 报警。 > **我曾经遇到一个坑**:某个国产网关把 TLS 记录长度写成 0xFFFF(对应 65535),客户端解析直接崩溃。其实标准要求上限是 16384。那个 Bug 修了快两周,最后发现是对方硬编码了一个 64K 的缓冲区,但也没检查长度。 ### 记录层的加密与完整性保护 记录层另一个核心职责——对上层数据做加密和 MAC。我个人习惯把这个过程叫“包装流水线”。 具体的处理流程(以 TLS 1.2 中的 AEAD 密码套件为例): 1. 生成一次性 nonce(每个记录递增的 sequence number + 部分 salt) 2. 对明文数据执行 AEAD 加密,输出密文 + 认证标签(Authentication Tag) 3. 用记录 header(type、version、length)和密文一起验证完整性 4. 接收端解密后,先检查认证标签,如果不一致就发 bad_record_mac 报警 换个角度说,每条记录被保护了三层: - **防重放**:sequence number 严格递增,不可回绕 - **防篡改**:AEAD 认证标签覆盖记录 header + 密文 - **防泄露**:密文加密,明文不可读
**一个值得注意的事**:TLS 1.3 里记录层的 version 字段固定为 0x0303(TLS 1.2),但实际协商版本用扩展字段指示。这是因为记录层的 version 在加密之前就已经确定了,没法动态改变。设计上是有意为之。
### 记录层的报警机制 报警消息(ContentType = 21)是记录层的重要反馈手段。比如你抓包看到 TLS 连接突然中断,多半是报警记录先来了。 常见的报警类型分两类: - **严重报警(fatal)**:接收端收到后必须立刻关闭连接,比如 bad_record_mac、handshake_failure、protocol_version 等。 - **警告报警(warning)**:只是提示,连接还可继续。比如 close_notify(通知对方我要正常关闭)、no_certificate(客户端没证书)等。但实践中大部分实现会把 warning 也当 fatal 处理——安全起见嘛。 我在生产排障时经常看报警码来定位错误。比如: - `alert 21 = decrypt_error`:通常发生在密码套件不匹配、密钥解密出错 - `alert 42 = bad_certificate`:证书验证失败,我要接着看 Certificate 消息的签名和链 ### 记录层内部的交互关系 我来画一张整个记录层协议的核心流程,更直观一些: TLS 记录层协议核心流程 握手层消息 报警消息 应用数据消息 ChangeCipherSpec 分片:最大 2¹⁴ 字节 可协商 max_fragment_length 压缩(可选) TLS1.3 已废弃 MAC + 加密 / AEAD 序列号 + 记录头部也参与计算 添加记录头 type | version | length | ciphertext 我画这个图的时候特意把加密阶段标红了,它是最容易出错的地方。MAC 计算错误、序列号不一致、或者密码套件选用错误的算法,都会导致解密失败。我在现场经常这样排查:先看记录头是否正常,再看解密过程报什么错——绝大多数问题就出在这两个地方。 ### 记录层的超时与重传 记录层虽然不负责丢包重传(那是 TCP 的事),但它会在握手阶段管理超时。比如握手消息的发送方会启动一个定时器,如果超时还没收到应答就重发上一次握手消息。有些实现还会动态调整重试间隔。
**小技巧**:如果你在抓包文件里看到大量重复的 TLS 握手消息(比如相同序列号的 ClientHello),那很可能是对方超时重传了。这时候优先排查网络延迟,而不是证书或密码套件。
### 记录层的常见错误与日志标识 我们最后整理一下你会在日志里看到的记录层相关错误,我都是从实际排障中提炼出来的: | 日志标识 | 含义 | 典型原因 | |----------|------|----------| | `bad_record_mac` | MAC 校验失败 | 加密密钥不匹配、数据被篡改、序列号回绕 | | `decrypt_error` | 解密失败 | 密钥无法解出数据、padding 格式错误 | | `record_overflow` | 记录长度超限 | 负载长度 > 16384 | | `unexpected_message` | 消息类型不对 | 比如握手没完成就收到应用数据 | | `protocol_version` | 版本不兼容 | 客户端与服务端 TLS 版本不一致 |
**特别注意**:`bad_record_mac` 也常由密码套件协商不一致引起,比如客户端想用 AES-GCM,服务端却用 RC4,中间某个代理转发了不对的密钥。排查时最好对比两端协商的 cipher suite 是否一致。
我习惯把所有跟记录层相关的错误归类到“封装与校验异常”这个大目录下。如果你日志中出现这些关键词,优先检查: 1. 抓包确认记录头的 content_type 和 version 是否在预期范围内 2. 确认密码套件两端是否一致(尤其是中间件) 3. 确认 sequence number 是否连续且没有重置(比如重启后忘记重置计数器) 嗯,到这里,记录层协议的核心就讲清楚了。你理解了这个容器,后续看握手层、证书验证这些就好办多了。