第二十五章 安全协议实现:TLS 1.3握手协议、C语言客户端实现
说实话,TLS 1.3 是我这几年用得最多的安全协议。以前做嵌入式设备时,老版本 TLS 1.2 的握手延迟让我头疼不已——设备启动后要等好几秒才能建立安全连接。后来升级到 1.3,效果立竿见影。
这一章,我们就来手写一个 TLS 1.3 的客户端。别怕,虽然协议本身很复杂,但核心流程其实就那几步。
25.1 TLS 1.3 握手流程概览
TLS 1.3 最大的变化是什么?握手次数从 2-RTT 降到了 1-RTT。如果你启用 0-RTT 模式,甚至可以在第一个数据包就发送应用数据。
我画了一张流程图,帮你快速建立整体认知:
你看,整个握手就三个主要消息交换。相比 TLS 1.2 的来回折腾,这简直太清爽了。
25.2 核心数据结构设计
写 C 语言实现,第一步就是定义好数据结构。我在项目中吃过亏——结构体设计不合理,后面改起来要命。
先看 TLS 1.3 的 Record 层结构:
// TLS 1.3 Record 头部
typedef struct {
uint8_t content_type; // 内容类型: 21=alert, 22=handshake, 23=application
uint16_t version; // 协议版本: 0x0303 (TLS 1.2 兼容), 0x0304 (TLS 1.3)
uint16_t length; // 负载长度
} __attribute__((packed)) tls_record_header_t;
// 握手消息头部
typedef struct {
uint8_t msg_type; // 握手消息类型: 1=ClientHello, 2=ServerHello
uint24_t length; // 消息体长度 (3字节)
uint16_t version; // 协议版本
} __attribute__((packed)) tls_handshake_header_t;
嗯,这里要注意 uint24_t 在标准 C 里没有直接定义。我一般这样处理:
typedef struct {
uint8_t bytes[3];
} uint24_t;
static inline uint32_t uint24_to_uint32(uint24_t v) {
return (v.bytes[0] << 16) | (v.bytes[1] << 8) | v.bytes[2];
}
为什么用 __attribute__((packed))?因为网络协议要求严格按字节对齐,编译器默认的对齐方式会插入填充字节,导致协议解析失败。我曾经因为这个 bug 排查了整整两天……
25.3 ClientHello 消息构造
ClientHello 是握手的起点。它包含客户端支持的密码套件、密钥共享等信息。
我个人习惯把构造过程拆成几个小函数:
// 构造 ClientHello 消息
int build_client_hello(uint8_t *out_buf, size_t *out_len) {
uint8_t *ptr = out_buf;
// 1. 写入 Record 层头部
tls_record_header_t *rec_hdr = (tls_record_header_t *)ptr;
rec_hdr->content_type = 0x16; // Handshake
rec_hdr->version = htons(0x0303); // TLS 1.2 兼容版本
ptr += sizeof(tls_record_header_t);
// 2. 写入握手消息头部
tls_handshake_header_t *hs_hdr = (tls_handshake_header_t *)ptr;
hs_hdr->msg_type = 0x01; // ClientHello
ptr += sizeof(tls_handshake_header_t);
// 3. 写入 ClientHello 内容
// 3.1 客户端随机数 (32字节)
generate_random(ptr, 32);
ptr += 32;
// 3.2 会话ID (TLS 1.3 中通常为空)
*ptr++ = 0x00; // 会话ID长度 = 0
// 3.3 密码套件
uint16_t cipher_suites[] = {
htons(0x1301), // TLS_AES_128_GCM_SHA256
htons(0x1302), // TLS_AES_256_GCM_SHA384
htons(0x1303) // TLS_CHACHA20_POLY1305_SHA256
};
uint16_t cipher_len = sizeof(cipher_suites);
memcpy(ptr, &cipher_len, 2);
ptr += 2;
memcpy(ptr, cipher_suites, cipher_len);
ptr += cipher_len;
// 4. 更新长度字段
uint16_t record_payload_len = ptr - out_buf - sizeof(tls_record_header_t);
rec_hdr->length = htons(record_payload_len);
uint24_t hs_len = {0};
uint32_t hs_payload_len = ptr - out_buf - sizeof(tls_record_header_t) - sizeof(tls_handshake_header_t);
hs_len.bytes[0] = (hs_payload_len >> 16) & 0xFF;
hs_len.bytes[1] = (hs_payload_len >> 8) & 0xFF;
hs_len.bytes[2] = hs_payload_len & 0xFF;
memcpy(&hs_hdr->length, &hs_len, 3);
*out_len = ptr - out_buf;
return 0;
}
25.4 密钥协商与派生
TLS 1.3 使用 (EC)DHE 密钥交换。客户端在 ClientHello 中发送自己的密钥共享,服务端回复它的密钥共享,然后双方各自计算出预主密钥。
密钥派生用的是 HKDF(HMAC-based Key Derivation Function)。流程如下:
// 密钥派生函数 (简化版)
int tls13_key_schedule(tls_connection_t *conn) {
uint8_t early_secret[32], handshake_secret[32], master_secret[32];
// 1. 计算 early_secret = HKDF-Extract(salt=0, PSK)
// 没有 PSK 时,PSK 为全0
uint8_t psk[32] = {0};
hkdf_extract(early_secret, NULL, 0, psk, 32);
// 2. 计算 handshake_secret
// HKDF-Extract(salt=early_secret, ikm=ECDHE共享密钥)
hkdf_extract(handshake_secret, early_secret, 32,
conn->ecdh_shared_secret, conn->ecdh_shared_len);
// 3. 派生握手密钥
// HKDF-Expand-Label(handshake_secret, "handshake key expansion", ...)
derive_handshake_keys(conn, handshake_secret);
// 4. 计算 master_secret
hkdf_extract(master_secret, handshake_secret, 32, NULL, 0);
// 5. 派生应用数据密钥
derive_application_keys(conn, master_secret);
return 0;
}
HKDF-Expand-Label 使用的标签字符串必须严格遵循规范。比如 "tls13 key expansion" 中间的空格、大小写都不能错。我曾经因为标签里多了一个空格,导致握手失败,排查了三个小时才发现。
25.5 证书验证
收到服务端的证书后,客户端需要验证证书链。这一步不能省,否则中间人攻击就防不住了。
验证流程:
- 解析证书链:从 ServerHello 后的 Certificate 消息中提取证书列表
- 验证签名:用上一级证书的公钥验证当前证书的签名
- 检查有效期:确认证书在有效期内
- 检查吊销状态:通过 CRL 或 OCSP 确认证书未被吊销
- 验证主机名:确认证书中的 CN 或 SAN 匹配目标主机名
int verify_certificate_chain(x509_cert_t *certs, int cert_count) {
// 从叶子证书开始,逐级向上验证
for (int i = 0; i < cert_count - 1; i++) {
x509_cert_t *subject = &certs[i];
x509_cert_t *issuer = &certs[i + 1];
// 验证签名
if (!x509_verify_signature(subject, issuer->public_key)) {
return -1; // 签名验证失败
}
// 检查有效期
time_t now = time(NULL);
if (now < subject->valid_from || now > subject->valid_to) {
return -2; // 证书过期或尚未生效
}
}
// 验证根证书是否在信任存储中
if (!is_trusted_root(&certs[cert_count - 1])) {
return -3; // 根证书不受信任
}
return 0;
}
25.6 完整的客户端实现
把上面的模块组合起来,就是一个完整的 TLS 1.3 客户端:
int tls13_connect(const char *hostname, uint16_t port) {
tls_connection_t conn;
memset(&conn, 0, sizeof(conn));
// 1. 建立 TCP 连接
conn.sock = tcp_connect(hostname, port);
if (conn.sock < 0) return -1;
// 2. 发送 ClientHello
uint8_t client_hello[1024];
size_t ch_len;
build_client_hello(client_hello, &ch_len);
send(conn.sock, client_hello, ch_len, 0);
// 3. 接收 ServerHello
uint8_t server_hello[2048];
ssize_t sh_len = recv(conn.sock, server_hello, sizeof(server_hello), 0);
if (sh_len <= 0) return -2;
// 4. 解析 ServerHello,提取密钥共享
parse_server_hello(&conn, server_hello, sh_len);
// 5. 计算共享密钥
compute_ecdh_shared_secret(&conn);
// 6. 密钥派生
tls13_key_schedule(&conn);
// 7. 接收并验证证书
uint8_t cert_msg[4096];
ssize_t cm_len = recv(conn.sock, cert_msg, sizeof(cert_msg), 0);
parse_and_verify_certificates(&conn, cert_msg, cm_len);
// 8. 接收并验证 Finished 消息
uint8_t finished[256];
ssize_t f_len = recv(conn.sock, finished, sizeof(finished), 0);
verify_server_finished(&conn, finished, f_len);
// 9. 发送客户端 Finished
send_client_finished(&conn);
// 10. 握手完成,开始加密通信
conn.handshake_done = 1;
return 0;
}
25.7 避坑指南
最后分享几个我踩过的坑:
- 版本协商:TLS 1.3 的 supported_versions 扩展必须正确设置。如果只设置 Record 层的版本为 0x0303,服务端可能认为你只支持 TLS 1.2。
- 密钥更新:TLS 1.3 支持密钥更新(KeyUpdate),但很多实现不完善。如果你的客户端需要长时间连接,一定要处理好这个流程。
- 0-RTT 数据:0-RTT 虽然快,但有重放攻击风险。我建议只在幂等操作(如查询)中使用,不要在 0-RTT 中执行写操作。
- 内存管理:证书链可能很大(有些服务器会发 4-5 级证书),注意缓冲区大小。我习惯用动态分配,避免栈溢出。
好了,TLS 1.3 客户端实现的核心内容就这些。代码量虽然不少,但每个模块的职责都很清晰。动手写一遍,你会对协议有更深的理解。
公众号:蓝海资料掘金营,微信deep3321