引言
绝大多数网络应用都需要 TLS,但多数语言要求你链接 OpenSSL 或 BoringSSL——一个庞大的 C 依赖,带着自己的构建系统和安全公告。Zig 走了一条不同的路:标准库自带 TLS 1.3 客户端与全套密码学原语(std.crypto),纯 Zig 实现,交叉编译时不需要任何外部库。
本文覆盖:密码学基础与证书体系、std.crypto 的哈希/对称/非对称原语、TLS 1.3 握手、std.crypto.tls 客户端、HTTPS 服务器搭建,以及 HTTP/2 的帧结构、HPACK 与多路复用。代码基于 Zig 0.13/0.14。
前置:TCP 与 HTTP 基础、哈希与签名原语。
目录
- 1. 协议栈全景与分层
- 2. 证书、密钥与信任链
- 3. std.crypto 密码学原语
- 4. TLS 1.3 握手流程
- 5. 使用 std.crypto.tls 客户端
- 6. 构建 HTTPS 服务端
- 7. HTTP/2 帧与多路复用
- 8. 证书校验与安全实践
- 9. 性能调优与连接复用
- 速查表
- 一句话记忆
- 相关阅读
- 延伸阅读
1. 协议栈全景与分层
1.1 分层结构
┌──────────────────────────────┐
│ HTTP/2 帧层 / HPACK 压缩 │
├──────────────────────────────┤
│ TLS 1.3 记录层 / 握手 │
├──────────────────────────────┤
│ TCP 可靠字节流 │
├──────────────────────────────┤
│ IP │
└──────────────────────────────┘
HTTP/2 是二进制分帧协议,建立在 TLS 之上(实践中几乎总是 h2 + TLS,即 ALPN 协商 h2)。TLS 提供机密性、完整性与身份认证。
1.2 TLS 1.3 的变化
相比 1.2,TLS 1.3 做了大幅精简:
- 握手从 2-RTT 降到 1-RTT,支持 0-RTT 恢复。
- 移除了 RSA 密钥交换、CBC 模式、RC4、SHA-1 等过时算法。
- 只保留 AEAD 密码套件(AES-GCM、ChaCha20-Poly1305)。
- 加密范围扩大到握手的大部分内容。
1.3 Zig 的取舍
Zig 标准库提供 TLS 客户端,服务端实现需要借助第三方库或反向代理(如 nginx 终止 TLS)。这是刻意的:客户端是绝大多数程序的需求,服务端 TLS 涉及证书管理与会话缓存,交给成熟组件更稳妥。
2. 证书、密钥与信任链
2.1 X.509 证书结构
证书本质是一个被 CA 签名的结构体,包含:
| 字段 | 说明 |
|---|---|
| Subject | 证书持有者(域名、组织) |
| Issuer | 签发者(CA 名称) |
| Public Key | 持有者的公钥 |
| Validity | 生效与过期时间 |
| SAN | Subject Alternative Name,实际校验的域名列表 |
| Signature | CA 用私钥对上述内容的签名 |
2.2 信任链验证
根 CA(自签名,预置在系统信任库)
↓ 签发
中间 CA
↓ 签发
服务器证书(example.com)
验证时从服务器证书向上追溯,每一级用上一级的公钥验证签名,直到命中本地信任的根 CA。
2.3 生成自签名证书
# 生成私钥
openssl genpkey -algorithm ED25519 -out key.pem
# 生成自签名证书(含 SAN)
openssl req -new -x509 -key key.pem -out cert.pem -days 365 \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"
踩坑:现代浏览器与客户端只认 SAN,不认 CN。缺少
subjectAltName的证书即使 CN 匹配也会校验失败。
3. std.crypto 密码学原语
3.1 哈希与 HMAC
const std = @import("std");
const crypto = std.crypto;
test "hash" {
var out: [32]u8 = undefined;
crypto.hash.sha2.Sha256.hash("hello", &out, .{});
var hmac = crypto.auth.hmac.sha2.HmacSha256.init("secret-key");
hmac.update("message");
var mac: [32]u8 = undefined;
hmac.final(&mac);
}
3.2 对称加密 AEAD
AES-256-GCM 与 ChaCha20-Poly1305 是 TLS 1.3 仅存的两种 AEAD:
test "aead" {
const key = [_]u8{0x42} ** 32;
const nonce = [_]u8{0x01} ** 12;
const plaintext = "机密数据";
const ad = "header";
var ciphertext: [plaintext.len]u8 = undefined;
var tag: [16]u8 = undefined;
crypto.aead.aes_gcm.Aes256Gcm.encrypt(
&ciphertext, &tag, plaintext, ad, nonce, key,
);
var decrypted: [plaintext.len]u8 = undefined;
crypto.aead.aes_gcm.Aes256Gcm.decrypt(
&decrypted, &ciphertext, tag, ad, nonce, key,
) catch unreachable;
try std.testing.expectEqualStrings(plaintext, &decrypted);
}
nonce 绝不能重复:同一密钥下重用 nonce 会灾难性地泄露明文异或关系。TLS 用序列号保证唯一。
3.3 非对称与密钥交换
test "x25519" {
const sk = crypto.ecc.X25519.KeyPair.generate();
const pk = sk.public_key;
const their_sk = crypto.ecc.X25519.KeyPair.generate();
const shared1 = try crypto.ecc.X25519.scalarmult(sk.secret_key, their_sk.public_key);
const shared2 = try crypto.ecc.X25519.scalarmult(their_sk.secret_key, pk);
try std.testing.expectEqual(shared1, shared2);
}
X25519 是 ECDHE 密钥交换的默认选择,双方各出私钥即可协商出共享密钥,前向安全。
3.4 签名
test "ed25519" {
const kp = crypto.sign.Ed25519.KeyPair.generate();
const msg = "签名内容";
const sig = try kp.sign(msg, null);
try sig.verify(msg, kp.public_key);
}
4. TLS 1.3 握手流程
4.1 消息序列
Client Server
│ ClientHello ────────────────▶ │ 支持的套件 + 密钥共享 + SNI
│ ◀──── ServerHello │ 选定套件 + 密钥共享
│ ◀──── {EncryptedExtensions} │ ← 之后全部加密
│ ◀──── {Certificate} │
│ ◀──── {CertificateVerify} │
│ ◀──── {Finished} │
│ {Finished} ─────────────────▶ │
│ Application Data ◀──────────▶ │
{} 表示已加密。1-RTT 完成握手后即可发送应用数据。
4.2 密钥派生
TLS 1.3 用 HKDF 从共享密钥派生出多个密钥:客户端写密钥、服务端写密钥、客户端 IV、服务端 IV。每次密钥更新(KeyUpdate)都会重新派生。
4.3 SNI 与 ALPN
- SNI(Server Name Indication):客户端在 ClientHello 里明文告知目标域名,让服务器选择对应证书。这是同一 IP 托管多个 HTTPS 站点的基础。
- ALPN(Application-Layer Protocol Negotiation):协商应用层协议,HTTP/2 的值是
h2,HTTP/1.1 是http/1.1。
5. 使用 std.crypto.tls 客户端
5.1 建立 TCP 连接
const std = @import("std");
pub fn openSocket(alloc: std.mem.Allocator, host: []const u8, port: u16) !std.net.Stream {
const stream = try std.net.tcpConnectToHost(alloc, host, port);
return stream;
}
5.2 包装为 TLS 客户端
const tls = std.crypto.tls;
pub fn connectTls(
alloc: std.mem.Allocator,
host: []const u8,
port: u16,
) !tls.Client {
const stream = try std.net.tcpConnectToHost(alloc, host, port);
var bundle: std.crypto.Certificate.Bundle = .{};
try bundle.rescan(alloc);
defer bundle.deinit(alloc);
var client = try tls.Client.init(stream, .{
.host = .{ .explicit = host },
.ca = .{ .bundle = bundle },
});
return client;
}
rescan 会扫描系统信任库(macOS 的 Keychain、Linux 的 /etc/ssl/certs),把根证书加载进 bundle。
5.3 发送与接收
try client.writeAll("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n");
var buf: [4096]u8 = undefined;
const n = try client.read(&buf);
std.debug.print("{s}\n", .{buf[0..n]});
TLS 客户端内部维护读写状态机,writeAll 负责分块加密,read 负责解密与重组。
5.4 内置 HTTP 客户端
更高层可以直接用 std.http.Client,它内部就用了 TLS:
var http_client = std.http.Client{ .allocator = alloc };
defer http_client.deinit();
var response_body = std.ArrayList(u8).init(alloc);
defer response_body.deinit();
const result = try http_client.fetch(.{
.location = .{ .url = "https://example.com/" },
.response_storage = .{ .dynamic = &response_body },
});
std.debug.print("状态码 {d},响应 {d} 字节\n", .{ @intFromEnum(result.status), response_body.items.len });
6. 构建 HTTPS 服务端
6.1 反向代理终止 TLS
生产环境最常见做法是让 nginx / Caddy 处理 TLS,Zig 服务只监听回环:
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-Proto https;
}
}
6.2 纯 Zig 的 HTTP 服务
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const alloc = gpa.allocator();
const addr = try std.net.Address.parseIp("127.0.0.1", 8080);
var server = try addr.listen(.{ .reuse_address = true });
defer server.deinit();
while (true) {
const conn = try server.accept();
defer conn.stream.close();
var buf: [4096]u8 = undefined;
_ = conn.stream.read(&buf) catch continue;
try conn.stream.writeAll(
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello",
);
}
}
6.3 判断是否走 HTTPS
反代场景下通过 X-Forwarded-Proto 头判断原始协议,用于生成正确的重定向 URL:
const proto = header_value(headers, "x-forwarded-proto") orelse "http";
if (!std.mem.eql(u8, proto, "https")) {
// 生成 301 到 https 地址
}
7. HTTP/2 帧与多路复用
7.1 帧头结构
每个 HTTP/2 帧有固定 9 字节头部:
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
const FrameHeader = struct {
length: u24,
type: u8,
flags: u8,
stream_id: u31,
pub fn parse(bytes: *const [9]u8) FrameHeader {
return .{
.length = (@as(u24, bytes[0]) << 16) | (@as(u24, bytes[1]) << 8) | bytes[2],
.type = bytes[3],
.flags = bytes[4],
.stream_id = std.mem.readInt(u32, bytes[5..9], .big) & 0x7fffffff,
};
}
};
注意所有多字节字段都是大端序,且 stream id 最高位保留。
7.2 帧类型
| 类型 | 值 | 用途 |
|---|---|---|
| DATA | 0x0 | 请求/响应体 |
| HEADERS | 0x1 | 头部块 |
| RST_STREAM | 0x3 | 终止流 |
| SETTINGS | 0x4 | 连接参数协商 |
| WINDOW_UPDATE | 0x8 | 流控窗口调整 |
| GOAWAY | 0x7 | 优雅关闭连接 |
7.3 多路复用
HTTP/2 的核心优势:同一条 TCP 连接上并发多个流,每个流有独立 stream id,互不阻塞(消除了 HTTP/1.1 的队头阻塞,但仍受 TCP 层队头阻塞影响,这正是 HTTP/3 用 QUIC 的原因)。
连接 1 ┌─ stream 1: GET /a
├─ stream 3: GET /b
├─ stream 5: GET /c
└─ stream 7: GET /d
7.4 HPACK 头压缩
HTTP/2 用 HPACK 压缩头部,两个机制:
- 静态表:61 个常见头(
:method、:path、content-type等)预定义索引。 - 动态表:连接内已发送的头被缓存,后续只发索引。
:method: GET 编码为单字节 0x82,而不是 14 字节的字符串——这就是 HPACK 的威力。
踩坑:动态表大小受 SETTINGS 帧的
SETTINGS_HEADER_TABLE_SIZE限制,编解码双方必须同步更新,否则会解出乱码。
8. 证书校验与安全实践
8.1 必须校验的三件事
- 签名链:证书由可信 CA 签发。
- 有效期:
notBefore <= now <= notAfter。 - 主机名:SAN 列表包含目标域名。
只做加密不做校验,等于把数据交给中间人。
8.2 常见漏洞
| 漏洞 | 后果 | 防御 |
|---|---|---|
| 不校验证书 | 中间人攻击 | 强制校验,禁用「跳过验证」开关 |
| 只校验 CN | 通配符绕过 | 校验 SAN |
| nonce 重用 | 明文泄露 | 用单调序列号 |
| 弱随机数 | 私钥可预测 | 用 std.crypto.random |
| 降级到 TLS 1.0 | 已知攻击 | 只允许 1.2+ |
8.3 安全随机数
var key: [32]u8 = undefined;
std.crypto.random.bytes(&key); // 密码学安全随机源
绝不要用 std.Random.DefaultPrng 生成密钥——它是可预测的伪随机。
8.4 时间安全比较
比较 MAC 或 token 时用常数时间函数,防止计时侧信道:
const ok = crypto.utils.timingSafeEql([32]u8, mac_a, mac_b);
9. 性能调优与连接复用
9.1 连接复用与 Keep-Alive
TLS 握手开销大(1-RTT + 非对称运算),复用连接是最大的性能杠杆。HTTP/2 天然复用单连接,HTTP/1.1 需要显式 Keep-Alive:
Connection: keep-alive
9.2 会话恢复
TLS 1.3 的 PSK 恢复(Session Resumption)能让重连降到 0-RTT,但 0-RTT 数据不具备前向安全且可重放,只应用于幂等请求。
9.3 密码套件选择
| 场景 | 推荐套件 |
|---|---|
| 有 AES 硬件加速 | TLS_AES_128_GCM_SHA256 |
| 无 AES 加速(ARM 移动端) | TLS_CHACHA20_POLY1305_SHA256 |
| 需抗量子 | X25519MLKEM768 混合密钥交换 |
9.4 调优清单
- 开启 TLS 1.3,禁用 1.0/1.1。
- 启用 OCSP Stapling,减少客户端验证往返。
- 证书链只发必要中间证书,减小握手体积。
- 启用 HSTS 头,避免 HTTP 到 HTTPS 的首次明文跳转。
- 用
tcp_nodelay关闭 Nagle,降低小包延迟。
速查表
| 需求 | API / 做法 |
|---|---|
| SHA-256 | crypto.hash.sha2.Sha256.hash(data, &out, .{}) |
| HMAC | crypto.auth.hmac.sha2.HmacSha256.init(key) |
| AEAD 加密 | crypto.aead.aes_gcm.Aes256Gcm.encrypt(...) |
| 密钥交换 | crypto.ecc.X25519.scalarmult(sk, pk) |
| 签名 | crypto.sign.Ed25519.KeyPair.sign(msg, null) |
| 安全随机 | std.crypto.random.bytes(&buf) |
| 常数时间比较 | crypto.utils.timingSafeEql(...) |
| 加载根证书 | bundle.rescan(alloc) |
| TLS 客户端 | tls.Client.init(stream, .{ .host = ..., .ca = ... }) |
| HTTP 客户端 | std.http.Client.fetch(.{ .location = ... }) |
| HTTP/2 帧头 | 大端序,9 字节,stream id 最高位保留 |
一句话记忆
TLS 1.3 用 X25519 交换密钥、HKDF 派生密钥、AEAD 加密数据,握手 1-RTT;HTTP/2 是二进制分帧 + 单连接多路复用 + HPACK 头压缩;Zig 用 std.crypto 自带全套原语、std.crypto.tls 做客户端、服务端交给反代——切记校验证书链、SAN 与有效期。
相关阅读
延伸阅读
- WebSocket 握手与实时通信
- 网络热路径优化
- 对接 OpenSSL 等外部密码学库
- Zig 专题 — Zig 系统编程专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。