第二十五章 安全协议实现: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 模式,甚至可以在第一个数据包就发送应用数据。

我画了一张流程图,帮你快速建立整体认知:

客户端 服务端 ClientHello (密钥共享、支持的密码套件) ServerHello + 证书 + 完成 (服务器密钥共享、证书链、Finished) 客户端完成 (Finished 消息) 🔒 加密应用数据通信 时间

你看,整个握手就三个主要消息交换。相比 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;
}
小提示:密码套件的顺序很重要。客户端会把最优先的放在前面,服务端会选择它支持的第一个。我一般把 AES-128-GCM 放首位,因为它在大多数平台上都有硬件加速。

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 证书验证

收到服务端的证书后,客户端需要验证证书链。这一步不能省,否则中间人攻击就防不住了。

验证流程:

  1. 解析证书链:从 ServerHello 后的 Certificate 消息中提取证书列表
  2. 验证签名:用上一级证书的公钥验证当前证书的签名
  3. 检查有效期:确认证书在有效期内
  4. 检查吊销状态:通过 CRL 或 OCSP 确认证书未被吊销
  5. 验证主机名:确认证书中的 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;
}
核心要点:TLS 1.3 握手的关键在于密钥协商和派生。只要这两个环节不出错,剩下的就是消息的组装和解析。建议你在实现时,先用 openssl s_client 抓包对比,确保每个字节都正确。

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