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

不到 500 行 C 实现 TLS 1.3 握手

文章导航

分类入口
cryptographytlssecurity
标签入口
#TLS#Cryptography#C#Security

源码下载

本文相关源码已整理,共 13 个文件。

打开下载目录 →

目录

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

完整代码:examples/tls13/


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);
}

密钥派生流程:

TLS 1.3 密钥调度

每个 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

延伸阅读

参考资料

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2025-11-15 · cryptography / security

全同态加密(FHE)技术详解

在数据为王的时代,数据隐私和安全变得至关重要。我们希望在利用数据带来价值的同时,保护其不被泄露。传统的数据加密技术(如 AES、RSA)可以有效地保护静态存储和传输中的数据,但一旦需要对数据进行计算或处理,就必须先解密。解密后的数据以明文形式暴露在内存中,极易受到攻击,这在云计算等第三方计算环境中构成了巨大的安全风险。

2025-12-11 · cryptography / security

OPAQUE(RFC 9807)详解:从协议原理到工程落地

在传统的用户名 + 密码登录系统中,服务器通常要么直接保存口令派生值(如 salt + hash(password)),要么依赖 TLS+密码的组合来实现认证。一旦服务器数据库被攻破,攻击者就可以对这些口令派生值做离线暴力破解,且整个系统的安全性高度依赖 TLS 与密码派生方案的组合是否正确实现。OPAQUE(The…

2026-03-20 · cryptography / security

密码学工程中最容易犯的 7 个错误

密码学最危险的不是算法被破解,而是正确的算法被错误地使用。本文梳理 7 个真实 CVE 中的密码学工程错误,附代码与修复方案。


By .