土法炼钢兴趣小组的算法知识备份

不到 500 行 C 实现 TLS 1.3 握手

目录

每次使用 HTTPS,浏览器都会在毫秒级完成一次 TLS 握手。TLS 1.3(RFC 8446)是这套协议的最新版本,相比 1.2 做了大刀阔斧的简化。本文用 不到 500 行 C 从零实现一次完整的 TLS 1.3 握手——不链接 OpenSSL,4 个密码学模块自己写,最后连上真正的 TLS 1.3 服务端拿到 HTTP 200。

完整代码:GitHub - examples/tls13/ | 下载 zip


1. 为什么要从零写 TLS 1.3

TLS 1.3 vs 1.2:关键变化

TLS 1.3 相比 1.2 有四个根本性改进:

特性 TLS 1.2 TLS 1.3
握手延迟 2-RTT 1-RTT(首次),0-RTT(恢复)
密钥交换 RSA / DHE / ECDHE 可选 ECDHE 必选(强制前向保密)
对称加密 CBC+HMAC / GCM / CCM 仅 AEAD(GCM / CCM / ChaCha20-Poly1305)
密码套件 数十种(含 RC4、3DES 等已知弱套件) 5 种(全部安全)
TLS 1.2 vs 1.3 握手对比

TLS 1.2 允许 RSA 静态密钥交换——服务端私钥泄漏即可解密所有历史会话。TLS 1.3 砍掉了这一模式,所有连接都使用临时椭圆曲线密钥交换(ECDHE),即使长期密钥泄漏也无法追溯解密,这就是前向保密(Forward Secrecy)。

TLS 1.2 的 CBC+HMAC 组合容易遭受 Padding Oracle 攻击(详见 CBC 攻击),TLS 1.3 彻底移除了非 AEAD 模式。

我们的目标

用 C 写一个 TLS 1.3 客户端:

⚠️ 教学代码,不可用于生产环境。 我们跳过了证书链验证、0-RTT、PSK 等关键安全机制。


2. TLS 1.3 握手全景

TLS 1.3 完整握手流程

TLS 1.3 的 1-RTT 握手分为三个阶段:

明文阶段

  1. Client → Server:ClientHello — 支持的密码套件、X25519 公钥、TLS 版本
  2. Server → Client:ServerHello — 选定的密码套件、Server 的 X25519 公钥

此时双方都能计算出 ECDHE 共享密钥,派生出 handshake traffic keys

Handshake 加密阶段(使用 handshake keys 加密)

  1. Server → Client:{EncryptedExtensions}
  2. Server → Client:{Certificate}
  3. Server → Client:{CertificateVerify}
  4. Server → Client:{Finished} — 验证整个握手的完整性
  5. Client → Server:[Finished]

Application 加密阶段(切换到 application keys)

  1. 双向传输应用层数据(HTTP、gRPC 等)

{} 表示用 server handshake key 加密,[] 表示用 client handshake key 加密。

Record Layer

所有 TLS 消息都封装在 Record 中:

ContentType (1B) | ProtocolVersion (2B: 0x0303) | Length (2B) | Fragment

TLS 1.3 的 encrypted record 外层始终标记为 ApplicationData (23),真正的 content type 藏在解密后的 payload 末尾。

Handshake 消息格式

HandshakeType (1B) | Length (3B) | Body

我们会用到的类型:ClientHello (1)、ServerHello (2)、EncryptedExtensions (8)、Certificate (11)、CertificateVerify (15)、Finished (20)。


3. 密码学积木——逐个击破

实现 TLS 1.3 需要四块积木:哈希(SHA-256)、密钥交换(X25519)、密钥派生(HKDF)、认证加密(AES-128-GCM)。

3.1 X25519 ECDHE 密钥交换

X25519 ECDHE 流程

X25519(Curve25519 上的 Diffie-Hellman)是 TLS 1.3 最常用的密钥交换算法。核心操作是标量乘法:给定私钥标量 \(k\) 和曲线上的点 \(u\),计算 \(k \times u\)

安全性来自椭圆曲线离散对数问题(ECDLP):已知公钥 \(A = a \times G\),在计算上无法反推出私钥 \(a\)

Montgomery Ladder

Curve25519 使用 Montgomery 形式,只需要 \(x\) 坐标即可完成计算。标量乘法通过 Montgomery Ladder 实现——遍历私钥的每一位,执行 conditional swap + differential addition:

static void x25519_scalar_mult(uint8_t out[32],
                                const uint8_t scalar[32],
                                const uint8_t point[32]) {
    fe u, x1, x2, z2, x3, z3;
    fe_unpack(u, point);
    fe_copy(x1, u);              /* x_1 = u */
    fe_one(x2); fe_zero(z2);     /* (x_2, z_2) = (1, 0) */
    fe_copy(x3, u); fe_one(z3);  /* (x_3, z_3) = (u, 1) */
    uint8_t k[32];
    memcpy(k, scalar, 32);
    k[0] &= 248; k[31] &= 127; k[31] |= 64;  /* clamp */

    int swap = 0;
    for (int t = 254; t >= 0; t--) {
        int kt = (k[t >> 3] >> (t & 7)) & 1;
        swap ^= kt;
        fe_cswap(x2, x3, swap);
        fe_cswap(z2, z3, swap);
        swap = kt;
        /* ... differential addition & doubling ... */
    }
    /* out = x2 * z2^(p-2) */
}

clamp 操作确保标量具有正确的位结构:清除低 3 位(保证被 8 整除,cofactor safety)、设置最高位(防止时序侧信道)、清除最高位(保证 < \(2^{255}\))。

我们的实现使用 5×51-bit limb 表示 \(\mathbb{F}_p\) 中的元素(\(p = 2^{255} - 19\)),完整代码约 295 行,通过 RFC 7748 §6.1 的两个测试向量。

3.2 HKDF 密钥派生

TLS 1.3 的密钥调度是一棵精心设计的树,所有加密密钥都从 ECDHE 共享密钥这个根推导而来。

Key Schedule

整个密钥调度基于两个原语:

TLS 1.3 在上层封装了两个函数:

/* HKDF-Expand-Label 构造特定的 info 结构 */
void hkdf_expand_label(const uint8_t *secret,
                       const char *label,
                       const uint8_t *context, size_t ctx_len,
                       uint8_t *out, size_t out_len) {
    uint8_t info[512];
    size_t p = 0;
    /* HkdfLabel = length(2) + "tls13 " + label + context */
    put_u16(info, (uint16_t)out_len); p += 2;
    size_t llen = 6 + strlen(label);  /* "tls13 " prefix */
    info[p++] = (uint8_t)llen;
    memcpy(info + p, "tls13 ", 6); p += 6;
    memcpy(info + p, label, strlen(label)); p += strlen(label);
    info[p++] = (uint8_t)ctx_len;
    if (ctx_len > 0) { memcpy(info + p, context, ctx_len); p += ctx_len; }
    hkdf_expand(secret, 32, info, p, out, out_len);
}

/* Derive-Secret = HKDF-Expand-Label(secret, label, transcript_hash) */
void derive_secret(const uint8_t *secret, const char *label,
                   const uint8_t *transcript_hash, uint8_t *out) {
    hkdf_expand_label(secret, label, transcript_hash, 32, out, 32);
}

密钥派生流程:

Early Secret = HKDF-Extract(0, PSK=0)     // 无 PSK 时为全零
    ↓ Derive-Secret("derived", "")
Handshake Secret = HKDF-Extract(derived, ECDHE_shared_secret)
    ├─→ client_handshake_traffic_secret  ──→ key + iv
    ├─→ server_handshake_traffic_secret  ──→ key + iv
    ↓ Derive-Secret("derived", "")
Master Secret = HKDF-Extract(derived, 0)
    ├─→ client_application_traffic_secret ──→ key + iv
    └─→ server_application_traffic_secret ──→ key + iv

每个 traffic secret 通过 HKDF-Expand-Label 提取出 16 字节 AES 密钥和 12 字节 IV。

3.3 AES-128-GCM(AEAD)

AES-GCM 结构

AES-GCM 是一种认证加密(AEAD),同时提供保密性和完整性。它由两部分组成:

void aes128_gcm_encrypt(const aes128_ctx *ctx,
                        const uint8_t nonce[12],
                        const uint8_t *aad, size_t aad_len,
                        const uint8_t *plain, size_t plain_len,
                        uint8_t *cipher, uint8_t tag[16]) {
    /* 1. H = AES(K, 0^128) */
    uint8_t H[16] = {0};
    aes128_encrypt_block(ctx, H, H);

    /* 2. AES-CTR with initial counter = 2 */
    aes_ctr(ctx, nonce, 2, plain, cipher, plain_len);

    /* 3. GHASH(H, AAD, cipher) */
    ghash(H, aad, aad_len, cipher, plain_len, tag);

    /* 4. Tag = GHASH_result XOR AES(K, nonce||0x00000001) */
    uint8_t E0[16];
    /* ... counter=1 block encryption and XOR ... */
}

在 TLS 1.3 中,每条记录的 nonce 由 IV 和序列号 XOR 构成:

uint8_t nonce[12];
memcpy(nonce, iv, 12);
for (int i = 0; i < 8; i++)
    nonce[4 + i] ^= (uint8_t)(seq >> (56 - 8 * i));

AAD(Additional Authenticated Data)是 Record 头的 5 个字节。解密时,如果 GHASH 标签不匹配,整条记录将被拒绝——这杜绝了 CBC Padding Oracle 攻击的可能。

3.4 SHA-256

SHA-256 是整个 TLS 1.3 密钥体系的底座——HMAC、HKDF、Transcript Hash 全部依赖它。我们实现的是标准的 Merkle-Damgård 结构,遵循 FIPS 180-4

实现约 130 行,通过 NIST 标准测试向量验证。


4. 核心握手代码走读

有了四块密码学积木,完整的握手逻辑就是把它们按 RFC 8446 规定的顺序串起来。

以下代码片段均为 tls13.c裁剪版,省略了错误处理和部分重复逻辑,只保留关键路径。完整可编译代码见 examples/tls13/tls13.c

4.1 TCP 连接 & ClientHello

/* TCP connect */
struct addrinfo hints = {.ai_socktype = SOCK_STREAM}, *res;
getaddrinfo(hostname, port, &hints, &res);
sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
connect(sock, res->ai_addr, res->ai_addrlen);

ClientHello 是客户端发送的第一条消息,它需要声明:

static void send_client_hello(const char *hostname,
                              uint8_t priv[32],
                              uint8_t *ch_buf, size_t *ch_len) {
    uint8_t pub[32];
    /* 从 /dev/urandom 生成 X25519 临时私钥 */
    FILE *f = fopen("/dev/urandom", "rb");
    fread(priv, 1, 32, f); fclose(f);
    x25519_base(pub, priv);  /* 计算公钥 pub = priv × G */

    uint8_t msg[512];
    size_t p = 0;
    /* Handshake type + length 占位(4 字节) */
    size_t hs_start = p; p += 4;
    put_u16(msg + p, TLS_VERSION_12); p += 2;  /* legacy version */
    /* 32 字节随机数(完整代码从 /dev/urandom 读取) */
    /* fread(msg + p, 1, 32, rng) */ p += 32;
    /* session_id(中间件兼容) */
    msg[p++] = 32; memset(msg + p, 0xAB, 32); p += 32;
    /* cipher_suites: 只有一个 */
    put_u16(msg + p, 2); p += 2;
    put_u16(msg + p, TLS_AES_128_GCM_SHA256); p += 2;
    /* compression: null */
    msg[p++] = 1; msg[p++] = 0;

    /* ── Extensions ── */
    /* supported_versions: TLS 1.3 */
    /* key_share: X25519 public key (32 bytes) */
    /* supported_groups: X25519 */
    /* signature_algorithms: ECDSA + RSA-PSS + RSA-PKCS1 */
    /* server_name (SNI) */
    /* ... (每个扩展都是 type(2) + length(2) + data) */
}

每条发出的 Handshake 消息都要加入 transcript hash——一个跨越整个握手的 SHA-256 滚动哈希。后续的密钥派生全部依赖它。

4.2 解析 ServerHello

ServerHello 的结构与 ClientHello 类似。我们需要提取的关键信息是 server 在 key_share 扩展中返回的 X25519 公钥:

static void recv_server_hello(uint8_t server_pub[32]) {
    uint8_t type, buf[16384];
    int len = record_recv(&type, buf, sizeof(buf));
    sha256_update(&transcript, buf, len);  /* 加入 transcript */

    /* 跳过固定字段,解析 extensions */
    size_t p = 4 + 2 + 32;        /* header + version + random */
    p += 1 + buf[p];              /* session_id */
    p += 2 + 1;                   /* cipher_suite + compression */
    size_t ext_len = get_u16(buf + p); p += 2;
    size_t ext_end = p + ext_len;

    while (p < ext_end) {
        uint16_t etype = get_u16(buf + p); p += 2;
        uint16_t elen  = get_u16(buf + p); p += 2;
        if (etype == EXT_KEY_SHARE) {
            p += 4;  /* group(2) + key_len(2) */
            memcpy(server_pub, buf + p, 32);
            return;
        }
        p += elen;
    }
}

4.3 派生 Handshake Keys

收到 ServerHello 后,双方都有了对方的 X25519 公钥,可以计算共享密钥并派生 handshake traffic keys:

static void derive_handshake_keys(const uint8_t priv[32],
                                  const uint8_t server_pub[32]) {
    /* ECDHE 共享密钥 */
    uint8_t shared[32];
    x25519(shared, priv, server_pub);

    /* Early Secret (无 PSK) */
    uint8_t zeros[32] = {0}, early_secret[32];
    hkdf_extract(NULL, 0, zeros, 32, early_secret);

    /* derived → Handshake Secret */
    uint8_t empty_hash[32], derived[32];
    sha256(NULL, 0, empty_hash);
    derive_secret(early_secret, "derived", empty_hash, derived);
    hkdf_extract(derived, 32, shared, 32, hs_secret);

    /* Transcript hash(到 ServerHello 为止) */
    sha256_ctx tmp = transcript;
    uint8_t th[32];
    sha256_final(&tmp, th);

    /* Traffic secrets → key + iv */
    derive_secret(hs_secret, "c hs traffic", th, c_hs_secret);
    derive_secret(hs_secret, "s hs traffic", th, s_hs_secret);

    uint8_t key_buf[16];
    hkdf_expand_label(s_hs_secret, "key", NULL, 0, key_buf, 16);
    aes128_init(&s_key, key_buf);
    hkdf_expand_label(s_hs_secret, "iv", NULL, 0, s_iv, 12);
    /* ... 同理 client side ... */
}

4.4 解密握手消息 & 验证 Server Finished

从这里开始,server 发来的所有消息都用 server_handshake_traffic_secret 加密。我们需要逐条解密并加入 transcript:

static int decrypt_record(uint8_t *inner_type,
                          uint8_t *plain, size_t *plain_len) {
    uint8_t type, buf[16384 + 256];
    int len = record_recv(&type, buf, sizeof(buf));

    /* 构造 nonce: IV XOR sequence_number */
    uint8_t nonce[12];
    memcpy(nonce, s_iv, 12);
    for (int i = 0; i < 8; i++)
        nonce[4 + i] ^= (uint8_t)(s_seq >> (56 - 8 * i));
    s_seq++;

    /* AAD = record header (5 bytes) */
    uint8_t aad[5] = {CT_APPLICATION_DATA, 0x03, 0x03, len>>8, len};
    aes128_gcm_decrypt(&s_key, nonce, aad, 5,
                       buf, len - 16, plain, buf + len - 16);

    /* 去除填充,找到真正的 content type */
    size_t plen = len - 16;
    while (plen > 0 && plain[plen-1] == 0) plen--;
    *inner_type = plain[--plen];
    *plain_len = plen;
    return 0;
}

解密后的握手消息流:EncryptedExtensions → Certificate → CertificateVerify → Finished。前三个直接加入 transcript,重点是验证 Server Finished

/* Server Finished = HMAC(finished_key, transcript_hash) */
uint8_t finished_key[32];
hkdf_expand_label(s_hs_secret, "finished", NULL, 0, finished_key, 32);

sha256_ctx tmp = transcript;  /* transcript 到 CertificateVerify 为止 */
uint8_t th[32];
sha256_final(&tmp, th);

uint8_t expected[32];
hmac_sha256(finished_key, 32, th, 32, expected);

if (memcmp(plain + off + 4, expected, 32) != 0) {
    /* 握手被篡改!中止连接。*/
    exit(1);
}

这一步是 TLS 1.3 握手完整性的核心保障:如果任何一条握手消息被篡改,transcript hash 就会不同,Finished 验证就会失败。

但要注意:Finished 验证只能证明对端持有正确的握手密钥、且 transcript 没被篡改;它不能证明”对端就是你想连接的那个网站”。 身份认证依赖于 Certificate + CertificateVerify——服务端用自己的私钥签署 transcript hash,客户端验证签名链直到受信任的根 CA。跳过这一步,中间人攻击者可以用自己的密钥对完成整个握手,Finished 一样能通过。

简化说明:我们跳过了证书链验证和 CertificateVerify 签名校验。生产环境中这两步必须完成,否则 TLS 握手的身份认证形同虚设。

4.5 发送 Client Finished

static void send_client_finished(void) {
    uint8_t finished_key[32];
    hkdf_expand_label(c_hs_secret, "finished", NULL, 0, finished_key, 32);

    sha256_ctx tmp = transcript;
    uint8_t th[32];
    sha256_final(&tmp, th);

    uint8_t verify_data[32];
    hmac_sha256(finished_key, 32, th, 32, verify_data);

    uint8_t msg[36];
    msg[0] = HT_FINISHED;
    put_u24(msg + 1, 32);
    memcpy(msg + 4, verify_data, 32);

    encrypt_send(CT_HANDSHAKE, msg, 36);
    sha256_update(&transcript, msg, 36);
}

注意:Client Finished 用的是 client_handshake_traffic_secret 加密,但 derive application keys 时使用的 transcript hash 只到 Server Finished 为止(不包含 Client Finished)。这是 RFC 8446 §7.1 的明确要求。

⚠️ 最容易踩的坑:如果你在发送 Client Finished 之后才取 transcript hash 去派生 application keys,hash 里会多出 Client Finished 这条消息,导出的 key/iv 与服务端不一致,后续所有应用数据都会解密失败。这是笔者实际调试时遇到的第一个”握手成功但应用数据全挂”的 bug——症状是 AES-GCM tag 校验报错,但根因在密钥派生的输入不对。正确做法是在 send_client_finished() 之前就把 transcript hash 快照下来。

4.6 切换到 Application Keys

/* 在发送 Client Finished 之前,保存 transcript hash */
sha256_ctx sf_ctx = transcript;   /* transcript 到 Server Finished */
uint8_t sf_hash[32];
sha256_final(&sf_ctx, sf_hash);

send_client_finished();           /* 用 handshake keys 加密发送 */

/* 从 Master Secret 派生 application keys */
derive_app_keys(sf_hash);

derive_app_keys 沿着 Key Schedule 继续向下:

static void derive_app_keys(const uint8_t sf_transcript_hash[32]) {
    uint8_t empty_hash[32], derived[32], master_secret[32];
    sha256(NULL, 0, empty_hash);
    derive_secret(hs_secret, "derived", empty_hash, derived);
    uint8_t zeros[32] = {0};
    hkdf_extract(derived, 32, zeros, 32, master_secret);

    uint8_t s_app_secret[32], c_app_secret[32];
    derive_secret(master_secret, "s ap traffic", sf_transcript_hash, s_app_secret);
    derive_secret(master_secret, "c ap traffic", sf_transcript_hash, c_app_secret);

    /* 从 traffic secrets 提取 key + iv,重新初始化加密上下文 */
    /* ... */
}

握手完成!此后发送一个 HTTP GET 验证连接可用:

char http_req[256];
int http_len = snprintf(http_req, sizeof(http_req),
    "GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", hostname);
encrypt_send(CT_APPLICATION_DATA, (uint8_t *)http_req, http_len);

4.7 工程上的”杂音消息”

实际对接时,你还会遇到两类协议规范之外的”干扰”:

  1. ChangeCipherSpec 兼容记录(content type 20)——TLS 1.3 已经废弃了 CCS,但为了穿透某些只认 TLS 1.2 流量模式的中间件(middlebox),服务端可能在 ServerHello 之后发一条空的 CCS。我们的 decrypt_record 直接跳过它。
  2. NewSessionTicket(handshake type 4)——握手完成后,服务端通常会立刻用 application keys 加密发送一到两条 session ticket,用于后续连接的 PSK 快速恢复。它们的外层 content type 是 23(ApplicationData),解密后 inner type 是 22(Handshake),很容易和真正的应用数据混淆。我们的做法是循环读取,跳过所有非 ApplicationData 的 inner type,直到拿到 HTTP 响应。

这两个问题在 RFC 里都有交代(§5.1 CCS 兼容、§4.6.1 NewSessionTicket),但如果你只照着握手流程图写代码,十有八九会在这里卡住。


5. 跑起来

编译

cd examples/tls13
make          # 编译 tls13 和 test_crypto
make test     # 运行 14 个 RFC 测试向量

所有密码学原语通过以下测试向量验证:

运行

由于我们跳过了证书验证,直接连公网站点(如 google.com:443)握手能成功,但无法确认对端身份——这恰恰印证了第 4.4 节的讨论。因此我们用本地 OpenSSL s_server 做端到端验证,它是一个标准的 TLS 1.3 实现,足以证明协议逻辑的正确性:

# 生成自签名证书
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
    -keyout key.pem -out cert.pem -days 365 -nodes \
    -subj "/CN=localhost"

# 启动服务端
openssl s_server -accept 14433 -cert cert.pem -key key.pem \
    -tls1_3 -www

另一个终端运行我们的客户端:

./tls13 localhost 14433

输出:

[+] TCP connected to localhost:14433
[+] ClientHello sent (199 bytes)
[+] ServerHello received
[+] Handshake keys derived
[+] Server Finished verified
[+] Client Finished sent
[+] Application keys derived
[+] HTTP request sent

HTTP/1.0 200 ok
Content-type: text/html

<HTML><BODY BGCOLOR="#ffffff">
<pre>
s_server -accept 14433 -cert cert.pem -key key.pem -tls1_3 -www
Secure Renegotiation IS NOT supported
...

[+] TLS 1.3 handshake complete!

HTTP 200——握手成功,应用数据正常收发。


6. 总结 & 延伸

我们简化了什么

简化项 生产环境要求
跳过证书验证 必须验证证书链到受信根 CA + CertificateVerify 签名
不支持 0-RTT Early Data 可进一步降低首次连接延迟
不支持 PSK Session Resumption 减少完整握手开销
不支持 HelloRetryRequest 密码套件协商失败时的重试机制
不支持 Post-handshake 认证 客户端证书(mTLS)在握手后请求
固定一个密码套件 协商支持多种密码套件和曲线
read/write 系统调用 需要非阻塞 I/O、超时、重试、缓冲区管理

代码量统计

模块 文件 行数 说明
SHA-256 sha256.c 128 FIPS 180-4
X25519 x25519.c 295 RFC 7748,Montgomery Ladder
HKDF hkdf.c 142 HMAC-SHA256 + HKDF + Derive-Secret
AES-128-GCM aes_gcm.c 325 AES 核心 + GCM (CTR + GHASH)
握手逻辑 tls13.c 459 Record Layer + 6 个握手函数(261 行) + main
测试 test_crypto.c 216 14 个 RFC 测试向量

tls13.c 的 459 行中,核心握手函数(send_client_helloderive_app_keys)占 261 行,去掉空行和注释后净逻辑约 180 行;其余是 Record Layer I/O 和 main

延伸阅读

参考资料


By .