本章导读
第 82 章你按下一个函数、握手自己跑完了;本章打开黑盒。TLS 1.3 的握手其实只有四条核心消息:ClientHello、ServerHello、(EncryptedExtensions、Certificate、CertificateVerify、)Finished,再加客户端一条 Finished——每条消息"谁发的、带什么、验证什么"都讲清楚,配上贯穿全程的密钥调度链(transcript 摘要 → handshake secret → 双向 traffic secret → 应用 secret——全部 HKDF 驱动,第 74/77 章的原语在此汇合)。然后是三个工程分支:HelloRetryRequest(服务端换组)、版本与套件协商(1.2/1.3 双栈)、KeyUpdate(长连接换钥)。本章示例用密钥交换原语和证书消息编解码两个独立入口,把握手的两块核心机械拆开单测——第 77 章的会话骨架在协议层的完全展开。
84.1引入
调试 TLS 失败时,"握手挂了"四个字没有任何信息量。是 ClientHello 的组不被支持(HelloRetryRequest 循环)?ServerHello 后证书验证失败?CertificateVerify 签名不成立?还是 Finished 摘要不匹配(transcript 被中间设备改了字节)?每一类失败对应不同的处置——而不懂时序的人只能重启碰运气。
理解时序的钥匙是一个洞察:TLS 1.3 握手的每一步都在"累积 transcript"——双方把收发的每条握手消息喂进同一份运行摘要,密钥从 transcript 派生、Finished 验证 transcript、恢复票据绑定 transcript。改任何一条消息的一个字节,后面所有密钥全变——这就是"握手防篡改"的机制本体,不是魔法是哈希链。带着这个视角读四条消息,每个字段都在回答"它为什么必须在这里"。
84.2概念
四条核心消息的时序图
三条阅读线。明文/密文边界在 ServerHello 之后——服务端的 EE/Cert/CV/Finished 全部加密,窃听者只见证书长度不见内容;每条消息验证什么:EE 验证协商一致性、CV 验证身份(第 80/81 章的验证在这里触发)、Finished 验证全局完整性;密钥切换点两处——ServerHello 后双方各自派生握手密钥(EE 起加密)、客户端 Finished 后切换应用密钥。
密钥调度:一条 HKDF 链
对照第 77 章的会话示例:同样的"ECDH → HKDF → AEAD"骨架,TLS 把它做成多级阶梯——每级 Extract/Expand 都把当时的 transcript 摘要绑进密钥,所以密钥不仅锁住秘密,还锁住"我们聊过的每一句话"。收发方向各有独立 secret(客户端写/服务端读用同一个,反向亦然)——单向换钥(KeyUpdate)因此成为可能。客户端临时私钥在握手密钥切换成功后立即擦除——前向保密的落点。
HelloRetryRequest:服务端说"换个组再来"
客户端首选组(如 x25519)服务端不支持时,服务端回 HRR 要求换组重发 ClientHello。XRT 客户端与服务端支持单次 HRR——重发的新 ClientHello 换成服务端指定的组(如 P-256)并带 h2 重试标记。对应用完全透明;对诊断重要:连接日志里"两次 ClientHello"不是重连,是 HRR。
版本与套件协商
双栈(1.2/1.3)协商规则:对端 offered 列表 ∩ 本地支持 → 最高版本;套件再过三重过滤——对端 offered 顺序 + 本地支持 + 身份类型兼容(RSA 身份排除纯 ECDSA 套件)。本地策略数组(第 85 章)就是这两步的输入基线;服务端每连接都跑这两步。xrtTlsVersionSelect/CipherSelect 把协商独立成可单测的纯函数——本章示例演示全流程。TLS 1.2 侧的要求:EMS(extended master secret)与 RFC 5746 空初始绑定必须,重新协商不提供——老协议的已知弱点被工程决策直接关闭。
KeyUpdate:长连接的换钥节奏
应用数据流到一定量,记录层自动换钥(对应用透明);也可主动 xrtTlsClientKeyUpdate(方向)。规则:KeyUpdate 消息独占一条记录;update_not_requested 只换读方向密钥;update_requested 先用旧写密钥排队应答再切换——任何 AGAIN 中断都不产生半更新状态(旧密钥、序列号、队列原样保留,输出排空后重试)。已排队数据的 epoch 顺序由线路队列保证——第 75 章"nonce 计数器"纪律在协议层的自动化。
证书消息:条目模型
TLS 1.3 的 Certificate 按条目组织(每条 = DER 证书 + 可选扩展如 OCSP 状态),1.2 是裸列表——xrtTlsCertificateEncode/Parse/Entries 统一处理两个版本(首参数给版本)。条目是 DER 视图,解析交给第 79 章的 xrtX509Parse。客户端状态机对条目扩展有严格规则:未请求的每条目扩展直接拒绝。
84.3示例
第一个完整程序:密钥交换的元数据驱动双向闭环
下面的程序来自 examples/tls/key_exchange/main.c——握手核心机械(key share 生成与派生)的独立单测:
#include <xrt.h> #include <stdio.h> #include <string.h> /* 选择当前构建中第一个可用命名组。 */ static const xtlsgroupinfo* exampleGroup(void) { static const uint16 Groups[] = { XTLS_GROUP_X25519, XTLS_GROUP_SECP256R1, XTLS_GROUP_X448, XTLS_GROUP_SECP384R1 }; for ( size_t i = 0; i < (sizeof(Groups) / sizeof(Groups[0])); i++ ) { if ( xrtTlsGroupAvailable(Groups[i]) ) { return xrtTlsGroupInfo(Groups[i]); } } return NULL; } */ /* 演示调用方按元数据提供缓冲,并完成双向共享秘密校验。 */ int main(void) { const xtlsgroupinfo* pInfo = exampleGroup(); uint8 ClientPrivate[56]; uint8 ClientPublic[97]; uint8 ServerPrivate[56]; uint8 ServerPublic[97]; uint8 ClientShared[56]; uint8 ServerShared[56]; if ( pInfo == NULL ) { return 1; } if ( !xrtTlsKeyShareGenerate( pInfo->Group, ClientPrivate, sizeof(ClientPrivate), ClientPublic, sizeof(ClientPublic) ) || !xrtTlsKeyShareGenerate( pInfo->Group, ServerPrivate, sizeof(ServerPrivate), ServerPublic, sizeof(ServerPublic) ) || !xrtTlsKeyShareDerive( pInfo->Group, (xbytesview) { ClientPrivate, pInfo->PrivateSize }, (xbytesview) { ServerPublic, pInfo->PublicSize }, ClientShared, sizeof(ClientShared) ) || !xrtTlsKeyShareDerive( pInfo->Group, (xbytesview) { ServerPrivate, pInfo->PrivateSize }, (xbytesview) { ClientPublic, pInfo->PublicSize }, ServerShared, sizeof(ServerShared) ) ) { return 1; } if ( memcmp(ClientShared, ServerShared, pInfo->SharedSize) != 0 ) { return 1; } printf("group=%u private=%u public=%u shared=%u\n", (unsigned)pInfo->Group, (unsigned)pInfo->PrivateSize, (unsigned)pInfo->PublicSize, (unsigned)pInfo->SharedSize); return 0; }
刚才发生了什么。 ① exampleGroup 按 x25519 → P-256 → x448 → P-384 的顺序找当前构建可用的组(xrtTlsGroupAvailable 反映裁剪与后端能力)——group=29 是 x25519 的 TLS 组号。② xtlsgroupinfo 给出三种缓冲的精确尺寸——私钥 32/公钥 32/共享 32(x25519 全 32;P-384 会是 48/97/48):缓冲按元数据开,不猜常数,同一份代码跑任何组。③ 双向 Derive 后 memcmp 相等——ECDH 对称性(第 76 章)在 TLS 组封装下的验证。这就是 ClientHello/ServerHello 里 key share 扩展的全部数学:Generate 两次、交换公钥、Derive 得到 ECDHE 共享秘密——密钥调度链的第一环。
第二个完整程序:Certificate 消息的编码与条目遍历
第二个程序来自 examples/tls/messages/main.c,证书链 → 消息正文 → 条目游标的完整往返:
#include <stdio.h> #include <string.h> #include <xrt.h> */ /* 编码并遍历一个两项 TLS 1.3 Certificate 正文。 */ int main(void) { static const uint8 Leaf[] = { 0x30, 0x01, 0x01 }; static const uint8 Issuer[] = { 0x30, 0x01, 0x02 }; xtlscertificateentry Chain[2]; uint8 Body[64]; xtlscertificatemessage Message; xtlscertificatecursor Cursor; xtlscertificateentry Entry; xtlsitemresult Result; size_t iBodySize; size_t iCount = 0; memset(Chain, 0, sizeof(Chain)); Chain[0].Data = (xbytesview) { Leaf, sizeof(Leaf) }; Chain[1].Data = (xbytesview) { Issuer, sizeof(Issuer) }; iBodySize = xrtTlsCertificateSize( XTLS_VERSION_13, (xbytesview) { NULL, 0 }, Chain, 2u ); if ( (iBodySize == 0) || !xrtTlsCertificateEncode( XTLS_VERSION_13, (xbytesview) { NULL, 0 }, Chain, 2u, Body, sizeof(Body) ) || !xrtTlsCertificateParse( XTLS_VERSION_13, (xbytesview) { Body, iBodySize }, &Message ) || !xrtTlsCertificateEntries(&Message, &Cursor) ) { return 1; } while ( (Result = xrtTlsCertificatesRead( &Cursor, &Entry )) == XTLS_ITEM_VALUE ) { printf("certificate[%zu]: %zu bytes\n", iCount, Entry.Data.Size); iCount++; } return (Result == XTLS_ITEM_DONE) && (iCount == 2u) ? 0 : 1; }
刚才发生了什么。 ① 两遍式编码:CertificateSize 先测正文长度(request_context 空视图是客户端方向;服务端方向的 CV 上下文非空),CertificateEncode 写入——两元素链(叶+签发者,这里是 3 字节玩具 DER)编码成 TLS 1.3 条目格式。② CertificateParse 产消息视图、Entries 建游标、Read 三态逐条——与 DER/PEM 游标同一套词汇(第 78 章)。③ 每条 Entry.Data 是 DER 视图——真正的解析交给第 79 章,两层各司其职:TLS 层管条目结构,X.509 层管证书语义。代理、抓包器、证书透明度日志都用这对入口处理线路证书。
84.4契约
- 协议范围:TLS 1.2/1.3 双栈;1.2 要求 EMS 与空初始绑定、无重新协商;Ed448 不发布;P-521/SHA-512 不进 1.3 offer。
- transcript 不可绕过:全部握手消息进摘要;密钥派生与 Finished 都绑定 transcript;CertificateVerify 的签名对象是"角色前缀 + 加入 CV 前的摘要"。
- 密钥切换原子性:任一步失败进 FAILED、不发布半组密钥;客户端临时私钥在切换成功后立即擦除(前向保密)。
- HRR:客户端与服务端支持单次;重试 ClientHello 换组并带标记;对应用透明。
- 协商规则:版本取交集最高;套件过 offered 顺序+本地支持+身份兼容三重过滤;策略数组是基线(第 85 章)。
- KeyUpdate:独占记录;两种请求模式;应答用旧写密钥;AGAIN 无半更新态;队列保证 epoch 顺序。
- 证书条目:1.3 条目式(可带扩展)、1.2 裸列表,Encode/Parse 统一入口;条目 DER 视图交 X.509 层;未请求的条目扩展拒绝。
- 错误映射:失败进
XTLS_STATE_FAILED并带结构化错误;XTLS_AGAIN只是"要更多输入/输出空间"。
常见错误
症状:监控里看到同一连接两条 ClientHello,误判为"客户端反复重连",触发告警风暴;或自写代理把 HRR 当错误断开。
原因:HelloRetryRequest 是协议内的正常分支——服务端要求换组重发。1.3 允许(XRT 支持单次);超过一次才是异常。
/* 抓包/日志层:见到第二次 ClientHello 就告警 */ if ( hello_count(client) > 1 ) { alert("client stuck in reconnect loop"); }
会话状态机已把它折叠成正常握手——日志层看状态而不是数包 */ if ( tls_state(session) == XTLS_STATE_FAILED ) { alert(failure_reason(session)); }
症状:自写中间层(代理、日志、加速器)改动了握手记录的边界——合并或拆分记录——之后 Finished 永远验证失败。
原因:transcript 摘要的对象是握手消息而非记录,但消息完整性依赖记录层的字节保真;任何对线路字节的"优化"(重组、重编码)都会改变 transcript,Finished 的 HMAC 因此失配——这正是协议要检测的篡改。
/* 中间层"整理"记录边界后转发 */ merge_small_records(&Inbound); /* transcript 变了 */
/* 透明转发原始字节;要解析就用记录解析层只读处理 */ /* (第 87 章的记录层只读入口就是为这类工具准备的) */
症状:xrtTlsPolicyValid 拒绝配置——"签名列表包含 1.2 专用方案";或反过来,纯 1.2 部署被塞了 1.3-only 套件。
原因:策略校验保证每条列表与启用版本自洽:每个套件对应启用版本、非空签名列表能用于至少一个启用版本——1.2 的 PKCS#1 方案混进纯 1.3 策略会被拦截。这是策略层的价值:把配置错误拦在启动期。
static const xtlsversion Versions[] = { XTLS_VERSION_13 }; Policy.Versions = Versions; Policy.VersionCount = 1; /* SignatureCount 没改:还是默认全家(含 1.2 专用)→ Valid 拒绝 */
static const xtlsversion Versions[] = { XTLS_VERSION_13 }; static const xtlssignature Sigs13[] = { /* 只放 1.3 方案 */ }; Policy.Versions = Versions; Policy.VersionCount = 1; Policy.Signatures = Sigs13; Policy.SignatureCount = /* 与数组一致 */; if ( !xrtTlsPolicyValid(&Policy) ) { /* 启动期报配置错误 */ }
编程练习
把 exampleGroup 的组顺序改成 P-256 优先,重跑 key_exchange——观察 group 号与三种尺寸的变化(97 字节公钥)。再注释掉所有组验证 Available 的拒绝路径。验收标准:两种组都闭环成功;尺寸变化与 xtlsgroupinfo 文档一致。
用 xrtTlsCertificateEncode/Parse 分别以 XTLS_VERSION_12 与 XTLS_VERSION_13 编码同一条目链,dump 两份正文的十六进制对比结构差异(1.2 无 request_context 与条目扩展)。验收标准:两份正文都能被对应版本的 Parse+Entries 遍历;能指出 1.3 多出的字段。
用记录解析层(第 87 章)或原始 socket 抓取一次完整握手(本机 dial 对任意站点),按本章时序图逐条标注:每条记录的类型、明文/密文边界、握手消息类型序列。验收标准:标注出的消息序列与本章时序图完全对应(含可能的一次 HRR);能指出 ServerHello 之后哪些内容你已经看不见了(密文开始)。
小结
| 知识点 | 速查 |
|---|---|
| 四消息 | ClientHello / ServerHello /(EE+Cert+CV)/ Finished + 客户端 Finished |
| 明密边界 | ServerHello 之后服务端消息全加密;窃听只见长度 |
| transcript | 全部握手消息的运行摘要;密钥与 Finished 都绑定它 |
| 密钥调度 | ECDHE → Extract 链(Early/Handshake/Master)→ 双向 traffic secret → key/IV |
| 方向独立 | 收发各有 secret;单向换钥(KeyUpdate)由此可能 |
| 前向保密 | 客户端临时私钥在握手密钥切换后立即擦除 |
| HRR | 服务端换组请求;单次;新 ClientHello 带标记;对应用透明 |
| 协商 | 版本取交集最高;套件过 offered+本地+身份兼容三重过滤 |
| KeyUpdate | 独占记录;应答用旧写密钥;AGAIN 无半更新;epoch 顺序有保证 |
| 证书条目 | 1.3 条目式 / 1.2 裸列表统一入口;条目 DER 视图交 X.509 层 |
| 1.2 边界 | EMS 必须无重新协商;1.2 专用方案进不了纯 1.3 策略 |
📖 本章涉及的 API 参考手册:tls(201 个条目)crypto(140 个条目)
点击查看完整签名、参数约束、返回值与错误说明