IPSec/IKEv2 的协议栈有多重?RFC 7296(IKEv2 核心)142 页,加上 EAP、MOBIKE、IKEv2 Redirect 等几十个扩展 RFC,StrongSwan 源码约 30 万行。内核侧的 xfrm 框架把安全策略数据库(SPD)和安全关联数据库(SAD)拆成两层——配置时看起来很灵活,出问题时很难判断是哪一层的行为和预期不一致。
WireGuard 的答案是:用约 4000
行内核代码做同样的事(Linux 5.6
初合并时的规模;当前主线 .c 源码约 5000
行,仍远小于 IPSec 栈)。这不是少写功能——WireGuard
提供了认证加密隧道需要的完整体验——而是系统地拒绝了很多”工程正确”的东西:拒绝证书体系、拒绝算法协商(cipher
suite negotiation)、拒绝 IKE 的多轮状态机、拒绝 SPD/SAD
的分层抽象。选型层面的对比见 VPN
技术工程对比 第三节。
替代这些的是三个核心设计:
- Cryptokey Routing:公钥就是路由表,身份 = 加密 = 转发
- Noise IKpsk2 握手:固定密码学原语,一次 1.5 RTT 的握手解决认证+密钥协商+前向保密
- 多核分段队列:平行加解密 + per-peer 串行保序
flowchart LR
subgraph IPSec[IPSec/IKEv2 协议栈]
IKE[IKEv2 守护进程<br/>RFC 7296, 142页]
XFRM[xfrm 框架<br/>SPD + SAD 双层]
CERT[证书体系<br/>X.509 + CRL/OCSP]
NEGO[算法协商<br/>cipher suite negotiation]
end
subgraph WG[WireGuard]
CK[公钥 ↔ AllowedIPs<br/>一张表]
NOISE[固定 Noise IKpsk2<br/>无协商]
SEND[发送路径<br/>~300 行 C]
RECV[接收路径<br/>~400 行 C]
end
IPSec --> IPSEC_COMPLEX[数十万行代码<br/>配置易出错]
WG --> WG_SIMPLE[~5000 行 C<br/>形式化可验证]
classDef bad fill:#f8514922,stroke:#f85149,color:#adbac7;
classDef good fill:#3fb95022,stroke:#3fb950,color:#adbac7;
class IPSEC_COMPLEX bad
class WG_SIMPLE good
上图是本文要拆解的三个问题的全景:左边是 WireGuard 拒绝的东西,右边是替代方案。下面从 cryptokey routing 开始,逐一钻进内核源码和协议规范。
一、Cryptokey Routing:公钥即路由表
传统 VPN 的数据模型是三层分离的:身份(证书/PSK)→ 密钥协商(IKE 的多轮握手)→ 安全关联(xfrm SA,绑定 SPI + 加密算法 + 密钥 + 源/目的 IP 范围)。配置时在三个不同的命名空间操作,出错时排查三个维度。
WireGuard 的数据模型只有一层:每个 peer 是一个
(公钥, AllowedIPs, Endpoint?) 三元组。
1.1 数据结构
Interface:
Private Key: yAnz...fBmk
Listen Port: 51820
Peer #1:
Public Key: xTIB...p8Dg
Allowed IPs: 10.192.122.3/32, 10.192.124.0/24
Endpoint: 198.51.100.1:51820
Peer #2:
Public Key: TrMv...WXX0
Allowed IPs: 10.192.122.4/32, 192.168.0.0/16
Endpoint: (自动学习)
这就是一个 WireGuard 接口的完整配置。没有证书链、没有 CA、没有加密算法选择、没有 IKE proposal 列表。公钥本身就是 peer 的唯一标识符——不需要把公钥包装进 X.509 证书再签名。
1.2 出方向:密钥选路
当内核协议栈有一个 plaintext IP 包要经由 wg0
发出时,ndo_start_xmit(即
wg_xmit)的处理逻辑:
- 取出目的 IP 地址
- 在 AllowedIPs trie 中查找——目的 IP 落在哪个 peer 的 AllowedIPs 范围里?
- 找到 peer → 用该 peer 的
current_keypair.sending_key加密 - 没找到 → 丢弃,返回
-ENOKEY,生成 ICMP “no route to host”
关键点:路由决策和加密密钥选择是同一个操作。不需要先查路由表再查
SPD 再查 SAD——一步完成。这对应于内核源码
allowedips.c 中的
wg_allowedips_lookup_dst()
调用点(device.c 的
wg_xmit()):
// device.c, wg_xmit() — 简化的发送入口
peer = wg_allowedips_lookup_dst(&wg->peer_allowedips, skb);
if (!peer)
goto err_nokey; // -ENOKEY
keypair = get_keypair(peer); // 来自 peer->keypairs.current_keypair
nonce = atomic64_inc_return(&keypair->sending_counter) - 1;
// 用 keypair->sending_key 和 nonce 做 AEAD 加密1.3 入方向:双向绑定
收到加密 UDP
包后的验证路径(receive.c;解密在 worker,源 IP
校验在 NAPI):
wg_packet_consume_data()从包头提取 receiver index → 查 hashtable 定位 keypair- 包进入
decrypt_queue,由wg_packet_decrypt_worker()解密 + 认证(ChaCha20-Poly1305) - 解密失败 → 静默丢弃
wg_packet_rx_poll()用counter_validate()检查 nonce 防重放wg_packet_consume_data_done()取出内层源 IP,调用wg_allowedips_lookup_src()并与 keypair 所属 peer 比对- 不匹配 → 静默丢弃
这意味着:如果 peer A(公钥
xTIB...p8Dg,AllowedIPs
10.192.122.3/32)试图发送一个源 IP 为
10.10.10.230
的包,即使它的加密和认证都是正确的,包也会被丢弃——因为
10.10.10.230 不在 10.192.122.3/32
里。
这个特性让基于 wg0 接口的
iptables/nftables
规则天然可靠:在 wg0 上看到的源 IP
是有密码学保证的,不可能被其他 peer 伪造。
1.4 为什么静默丢弃
WireGuard 对任何认证失败的包都静默丢弃,不返回 ICMP 错误、不发送 RST。这不是遗漏,而是安全设计:
- 返回错误 = 暴露”这里有一个人在监听” → 给攻击者信息
- 不解密就不知道包内容 → 也无法生成有意义的 ICMP
- 这与 TCP 的 RST 扫描防御策略一致:不响应 = 不存在
1.5 layering violation 的自觉
在 NDSS 2017 论文中 Donenfeld 明确承认 cryptokey routing 是一个故意的 layering violation——它把 L3 路由(网络层)和 L5+ 身份/加密(应用层/会话层)合并了。传统网络架构教条会说这是错的。但 IPSec 把这两个层分开的结果是 SPD/SAD 的配置地狱和调试噩梦。WireGuard 的选择是务实的:安全策略的最简表达就是”这个公钥可以使用这些 IP 地址”——多一层抽象不会让它更安全,只会让它更复杂。
二、Noise IKpsk2 握手:1.5 RTT 的完整路径
2.1 为什么是 Noise IK
WireGuard 选择了 Noise Protocol Framework 中定义的
IK 握手模式。Noise
框架用简洁的记号系统描述握手:
IK(s, rs):
<- s
...
-> e, es, s, ss, {t}
<- e, ee, se, psk, {}
记号解释:
| 记号 | 含义 |
|---|---|
<- s |
发起方预先知道响应方的静态公钥(配置在 peer 的公钥字段中) |
-> e |
发起方发送自己的临时公钥 |
es |
\(DH(e_i, s_r)\)——发起方临时 × 响应方静态 |
s |
发起方发送自己的静态公钥(用 es
派生的密钥加密) |
ss |
\(DH(s_i, s_r)\)——发起方静态 × 响应方静态 |
{t} |
用 ss 派生的密钥加密的
timestamp(防重放) |
<- e |
响应方发送自己的临时公钥 |
ee |
\(DH(e_i, e_r)\)——临时 × 临时(前向保密的来源) |
se |
\(DH(s_i, e_r)\)——发起方静态 × 响应方临时 |
psk |
可选预共享密钥 |
{} |
响应方可以在此携带加密 payload |
WireGuard 的实现是
Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s,在标准
IK 之上加了一个 preshared key 字段——psk
可以是空的,但如果配了,就额外引入一个对称密钥进入密钥派生,提供对量子计算的临时抗性(quantum
resistance)。WireGuard 不支持 cipher suite
negotiation——所有原语是固定的:
- DH:Curve25519
- AEAD:ChaCha20-Poly1305(RFC 7539)
- Hash/KDF:BLAKE2s + HKDF
2.2 握手第一步:Initiation
sequenceDiagram
participant I as Initiator
participant R as Responder
Note over I: 已知 R 的静态公钥 s_r
Note over I: 生成临时密钥对 e_i
I->>R: Message #1: Initiation
Note over I,R: 明文: e_i (临时公钥)
Note over I,R: es = DH(e_i, s_r) → 派生临时密钥 k0
Note over I,R: 加密: s_i (静态公钥)
Note over I,R: ss = DH(s_i, s_r) → 派生临时密钥 k1
Note over I,R: 加密: timestamp + MAC1
Note over R: 用 s_r 私钥 + e_i 算出 es
Note over R: 解密得到 s_i
Note over R: 用 s_r 私钥 + s_i 算出 ss
Note over R: 验证 timestamp 防重放
Note over R: 生成临时密钥对 e_r
第一步(noise.c 中的
wg_noise_handshake_create_initiation())发送:
- 明文
e_i(发起方临时公钥)——响应方用它与自己的静态私钥做 DH,得到es,派生临时加密密钥 \(k_0\) - 加密
s_i(发起方静态公钥)——用 \(k_0\) 加密。只有真正的响应方能解密(因为除了响应方自己,没人有 \(s_r\) 的私钥),这提供了发起方的身份隐藏(identity hiding):被动观察者看不到谁的密钥在跟响应方握手 - 加密
timestamp+mac1——用ss(\(DH(s_i, s_r)\))派生的密钥 \(k_1\) 加密。mac1是 cookie 机制的第一层防御(详见第五章)
这一步同时完成了两个 DH:es 和
ss。es 提供加密,ss
提供发起方认证——因为要算出正确的
ss,发起方必须持有 \(s_i\) 的私钥。
2.3 握手第二步:Response
sequenceDiagram
participant I as Initiator
participant R as Responder
Note over R: 已完成 initiation 验证
Note over R: 提取 s_i,生成 e_r
R->>I: Message #2: Response
Note over I,R: 明文: e_r (临时公钥)
Note over I,R: ee = DH(e_i, e_r)
Note over I,R: se = DH(s_i, e_r)
Note over I,R: 全部 DH 结果 + psk → HKDF 派生 session keys
Note over I,R: 加密: empty payload + MAC1/MAC2
Note over I: 用 e_i 私钥 + e_r 算出 ee
Note over I: 用 s_i 私钥 + e_r 算出 se
Note over I: 验证 session keys 正确性
Note over I: 保存 sending_key + receiving_key
第二步(noise.c 中的
wg_noise_handshake_create_response())发送:
- 明文
e_r(响应方临时公钥) - 加密
empty payload+mac1/mac2
这一步完成剩下的两个
DH:ee(临时-临时,前向保密的唯一来源——因为临时密钥对在握手后销毁)和
se(认证响应方——只有持有 \(s_r\) 私钥的人才能算出正确的
se)。
至此四个 DH 全部完成。WireGuard 把
es、ss、ee、se
四个 DH 结果和可选的 psk 一起送入 HKDF(BLAKE2s),派生
session。双方各自得到两把对称密钥——sending_key 和
receiving_key,两边的 sending_key 互为对方的
receiving_key。同时各自获得一个初始化为 0 的 64-bit
nonce counter。
2.4 为什么是 1.5 RTT
Noise IK 名义上是 1-RTT 握手(两条消息交换后双方都有 session keys),但响应方在收到发起方的第一条 transport 消息之前不能安全地发送自己的数据。原因在于前向保密:
- 在 response 发送时(消息 #2),响应方已经完成了
ee(临时-临时 DH),知道自己的 sending 消息有前向保密属性 - 但响应方不知道发起方是否真的收到了 response。如果 response 丢包,发起方会用旧密钥重传 initiation,而响应方已经切到了新 session——此时如果响应方发出了 transport 数据,而这些数据因为某种攻击被记录下来,之后有妥协旧静态密钥的风险
- 只有当发起方用新 session 的 sending_key 发出的第一条 transport 消息到达后,响应方才能确定”发起方确实收到了我的 response 并且也切到了新 session”——此时才获得强前向保密(strong forward secrecy)
因此实际可安全发送数据的延迟:
0 RTT: 发起方发送 Initiation
0.5 RTT: 响应方收到,发送 Response
1 RTT: 发起方收到 Response → 可以安全发送数据了
1.5 RTT: 响应方收到发起方的第一条 Transport → 可以安全发送数据了
这就是”1.5 RTT”的真实含义——不是协议规范定义的,而是安全边界推导的结果。Dowling & Paterson(ACNS 2018)在形式化分析中确认了这一属性。
2.5 与 TLS 1.3 的对比
| 维度 | TLS 1.3 (EC)DHE | WireGuard (Noise IKpsk2) |
|---|---|---|
| 往返 | 1-RTT(0-RTT 可选) | 1-RTT(1.5 RTT 到安全数据) |
| DH 操作 | 1 次(ECDHE) | 4 次(es, ss, ee, se) |
| 身份隐藏 | 仅 server cert(client cert 在加密通道内) | 发起方身份对被动观察者隐藏 |
| KCI 抗性 | 取决于模式 | transport 消息具有 KCI 抗性 |
| 算法协商 | 支持 cipher suite negotiation | 固定原语,不协商 |
| 证书 | X.509(强制) | 无——公钥即身份 |
四次 DH 操作看起来比 TLS 1.3 的”贵”,但实际上:Curve25519 的 DH 运算极快(微秒级),四次 DH 的总开销远小于 TLS 1.3 的证书链验证(RSA/ECDSA 签名验证 + 证书链遍历)。WireGuard 用计算开销(四次快速的 X25519)换掉了 I/O 开销(没有证书链要传输和验证)——对网络延迟敏感的 VPN 场景,这个 trade-off 是正确的。
三、内核数据路径:平行加密 + 串行保序
WireGuard
内核模块(drivers/net/wireguard/)的代码组织如下(行数取自
torvalds/linux 主线,2026-06):
| 文件 | 职责 | 行数 |
|---|---|---|
device.c |
net_device
注册、ndo_open/ndo_stop/ndo_start_xmit |
475 |
noise.c |
Noise IKpsk2 握手状态机、keypair 管理 | 861 |
send.c |
发送路径:排队 → 加密 → UDP 发送 | 414 |
receive.c |
接收路径:UDP → cookie 验证 → 解密 → NAPI 上栈 | 586 |
queueing.c / queueing.h |
多核队列基础设施(ptr_ring、prev_queue) |
313 |
timers.c |
握手重传 / keepalive / rekey / zero-key-material 定时器 | 246 |
cookie.c |
MAC1/MAC2 cookie DoS 防御 | 236 |
allowedips.c |
Trie IP 查找(路由) | 424 |
peerlookup.c |
公钥 / handshake index hashtable | 226 |
netlink.c |
Generic Netlink 配置接口 | 607 |
socket.c |
UDP socket 收发与 endpoint 管理 | 436 |
peer.c / main.c /
ratelimiter.c |
peer 生命周期、模块入口、限速 | 540 |
.c 文件合计约 5160
行。下面以发送和接收两条数据路径为轴,拆解这些文件如何协同工作。
3.1 发送路径:从 IP 包到加密 UDP
flowchart TD
STACK[内核协议栈<br/>plaintext IP 包] --> XMIT[ndo_start_xmit<br/>wg_xmit]
XMIT --> ALLOWED{wg_allowedips_lookup_dst<br/>目的 IP 查找}
ALLOWED -->|命中| STAGE[入队<br/>peer.staged_packet_queue]
ALLOWED -->|未命中| DROP[ICMP HOST_UNREACH<br/>-ENOKEY]
STAGE --> STAGED[wg_packet_send_staged_packets<br/>分配 nonce]
STAGED --> PQ[peer.tx_queue<br/>prev_queue 保序]
STAGED --> EQ[device.encrypt_queue<br/>ptr_ring]
EQ --> ENC1[encrypt_worker #1]
EQ --> ENC2[encrypt_worker #2]
EQ --> ENCN[encrypt_worker #N]
ENC1 --> PQ
ENC2 --> PQ
ENCN --> PQ
PQ --> TX[transmit_packet_work<br/>per-peer 串行发送]
TX --> UDP[wg_socket_send_skb_to_peer<br/>send4/send6]
classDef kernel fill:#388bfd22,stroke:#388bfd,color:#adbac7;
class XMIT,ALLOWED,STAGE,STAGED,PQ kernel
classDef parallel fill:#f0883e22,stroke:#f0883e,color:#adbac7;
class ENC1,ENC2,ENCN parallel
发送路径的核心设计是设备级并行加密 + peer 级串行发送:
入队与 nonce 分配:
wg_xmit()把包放进peer->staged_packet_queue,wg_packet_send_staged_packets()用atomic64_inc_return(&keypair->sending_counter)分配 nonce,再通过wg_queue_enqueue_per_device_and_peer()同时写入peer->tx_queue(prev_queue,保序)和wg->encrypt_queue(ptr_ring,并行)。并行加密阶段:
wg_packet_encrypt_worker()在packet_crypt_wq上运行,每个 CPU 核一个 worker,从encrypt_queue取包,调用chacha20poly1305_encrypt_sg_inplace()原地加密,完成后把状态标为PACKET_STATE_CRYPTED并唤醒对应 peer 的transmit_packet_work。串行发送阶段:
transmit_packet_work按tx_queue的 FIFO 顺序等待加密完成再发 UDP。加密可以乱序完成,但发送顺序与入队顺序一致。
这种设计解决了”要并行加密以获得吞吐量”和”要保序发送以满足
IP 语义”之间的矛盾。prev_queue 负责 peer
内保序,ptr_ring 负责跨 CPU
分发加密任务(定义见 queueing.h)。
发送路径有一个重要的安全实现细节:nonce
上限。messages.h 定义
REKEY_AFTER_MESSAGES = 2^{60}、REJECT_AFTER_MESSAGES
接近 \(2^{64}\)。计数器越过
REKEY_AFTER_MESSAGES 或 keypair 超过
REJECT_AFTER_TIME(180s)后,keep_key_fresh()
会触发新握手,而不是继续用旧 keypair 发包。
3.2 接收路径:从 UDP 到网络栈
flowchart TD
UDP[UDP 包到达<br/>wg_packet_receive] --> SKB[prepare_skb_header<br/>检查包类型]
SKB -->|handshake/cookie| HQ[handshake_queue<br/>ptr_ring]
HQ --> HW[handshake_receive_worker<br/>wg_packet_handshake_receive_worker]
HW --> H_NOISE[noise 握手消费<br/>initiation / response / cookie]
SKB -->|data| DEC[wg_packet_consume_data<br/>定位 keypair]
DEC -->|未找到 keypair| DROP[静默丢弃]
DEC -->|找到 keypair| DECQ[peer.rx_queue +<br/>device.decrypt_queue]
DECQ --> D1[decrypt_worker #1]
DECQ --> D2[decrypt_worker #2]
DECQ --> DN[decrypt_worker #N]
D1 --> NAPI[peer NAPI poll<br/>wg_packet_rx_poll]
D2 --> NAPI
DN --> NAPI
NAPI --> REPLAY{counter_validate<br/>RFC 6479}
REPLAY -->|重放| DROP
REPLAY -->|合法| ALLOWED{wg_allowedips_lookup_src<br/>源 IP 验证}
ALLOWED -->|不匹配| DROP
ALLOWED -->|匹配| GRO[GRO / 上栈<br/>napi_gro_receive]
classDef kernel fill:#388bfd22,stroke:#388bfd,color:#adbac7;
class UDP,SKB,DEC,REPLAY,ALLOWED kernel
classDef parallel fill:#f0883e22,stroke:#f0883e,color:#adbac7;
class D1,D2,DN parallel
接收路径的关键设计决策:
1. 不认证不分配内存。在
prepare_skb_header()
中检查包的基本结构(长度、类型、receiver
index),如果结构不合法直接丢弃,不调用
kmalloc()。这是对内存耗尽 DoS
的防御编码——任何未认证的包都不会导致内核内存分配。
2. Receiver index 快速定位。每个包携带 32-bit receiver index,接收方用 index 在 hashtable 中 O(1) 查找对应的 keypair。不需要像传统 IPSec 那样用 SPI + 目的 IP 复合查找。
3. per-peer NAPI 轮询。每个
wg_peer 自带
napi_struct,wg_packet_rx_poll()
从 peer->rx_queue 取解密完成的包,在 softirq
上下文中批量上栈,并与 GRO 配合聚合小包。
4. 防重放窗口。解密后的 nonce
需要过滑动窗口验证(counter_validate(),实现遵循
RFC 6479)。messages.h 用 8192-bit 位图记录近期
nonce(64 位平台上有效窗口约
8128),超出窗口的旧序号会被拒绝——大量丢包后可能需要重新握手。
3.3 per-peer 隔离
encrypt_queue / decrypt_queue
挂在 wg_device 上,由多核 worker 共享;但每个
wg_peer 有自己的
tx_queue、rx_queue(prev_queue)、staged_packet_queue、napi
和 keypairs。这意味着:
- peer A 与 peer B 的加解密任务可以落在不同 CPU 核上并行执行
- peer A 的
transmit_packet_work只阻塞本 peer 的发送顺序,不拖慢 peer B - peer A 的握手重传 timer 独立驱动,不影响 peer B 的数据面
这与 IPSec 的差异:xfrm SA 是全局的——如果有 1000 个 SA 同时需要 rekey,IKE 守护进程可能需要串行处理。WireGuard 没有这个问题,因为每个 peer 的 rekey 是独立 timer 驱动的。
四、Timer 状态机
WireGuard 没有 TCP 那样的”连接”概念——它只有 timer 驱动的状态转换。
stateDiagram-v2
[*] --> Idle: peer 创建
Idle --> HandshakeInitiated: 有数据要发<br/>或 keep_key_fresh 触发
HandshakeInitiated --> HandshakeInitiated: timer_retransmit_handshake<br/>5s + jitter,最多 18 次
HandshakeInitiated --> SessionEstablished: response 收到 + session 派生
SessionEstablished --> SessionEstablished: 正常收发数据
SessionEstablished --> HandshakeInitiated: REKEY_AFTER_TIME<br/>120s(发起方)
SessionEstablished --> HandshakeInitiated: REKEY_AFTER_MESSAGES<br/>2^60 包
SessionEstablished --> HandshakeInitiated: timer_new_handshake<br/>15s 无入站包
SessionEstablished --> SessionDead: timer_zero_key_material<br/>540s 无新密钥
HandshakeInitiated --> SessionDead: 握手重传超限
SessionEstablished --> SessionEstablished: timer_persistent_keepalive<br/>(保持 NAT 映射)
SessionDead --> [*]: peer 销毁或配置移除
各 timer 的语义(常量定义在
messages.h):
| Timer | 默认值 | 作用 |
|---|---|---|
timer_retransmit_handshake |
REKEY_TIMEOUT = 5s +
jitter;MAX_TIMER_HANDSHAKES = 18 |
发起方没收到 response 时重传 initiation |
timer_send_keepalive |
KEEPALIVE_TIMEOUT = 10s |
收到包后一段时间未发送,补发空 transport 包 |
timer_new_handshake |
KEEPALIVE_TIMEOUT + REKEY_TIMEOUT = 15s +
jitter |
发送后长时间未收到任何认证包,发起新握手 |
timer_zero_key_material |
REJECT_AFTER_TIME * 3 = 540s |
session 派生后长期收不到新密钥,清零密钥材料 |
timer_persistent_keepalive |
未配置时关闭 | 按 persistent_keepalive_interval
周期发包维持 NAT 绑定 |
Rekey 与上述 liveness timer
是两套机制:keep_key_fresh()(send.c)在发送路径检查
REKEY_AFTER_TIME(120s)和
REKEY_AFTER_MESSAGES(\(2^{60}\)),满足条件就排队新握手;timer_new_handshake
则处理”发了但对面没回”的活性检测。
Rekey 过程是无缝的——新握手产生
next_keypair,握手成功后
next_keypair 变成
current_keypair,旧的
current_keypair 退为
previous_keypair。previous_keypair
保留到
REJECT_AFTER_TIME(180s)到期,用于接收用旧密钥发出的在途包。这个设计确保
rekey 不会丢包。
所有 timer 的定义和回调都在 timers.c
中——它使用内核标准的 timer_list 和
mod_timer(),没有自定义的 timer wheel。
五、Cookie:无状态 DoS 防御
WireGuard 的 DoS 防御分为两档,核心思想是”攻击者不能让服务器做昂贵的 Curve25519 DH 运算”。
5.1 正常模式:仅 MAC1
在正常负载下,服务器只验证
mac1——这是一个基于发起方公钥 +
服务器 cookie secret 的快速 BLAKE2s
MAC。计算 mac1 是纯对称操作,比 Curve25519 DH
便宜几个数量级。
但攻击者如果知道一个合法 peer 的公钥(可以从公开的 wg 配置中获取),就能生成有效的 mac1——这不是防御,只是基本过滤。
5.2 高负载模式:MAC1 + MAC2(Cookie)
当全局握手队列深度达到
MAX_QUEUED_INCOMING_HANDSHAKES / 8(512)时进入
under_load 状态,并在负载回落后保留约 1
秒迟滞,避免抖动。此时仅带 MAC1 的 initiation 会被拒绝并返回
Cookie Reply:
sequenceDiagram
participant I as Initiator
participant R as Responder (under load)
I->>R: Initiation + MAC1 (仅 MAC1)
Note over R: handshake_queue_len ≥ 512<br/>MAC1 正确但不够
R-->>I: Cookie Reply + MAC2<br/>(cookie = MAC(secret, src_ip, src_port))
I->>R: Initiation + MAC1 + MAC2 (携带 cookie)
Note over R: MAC1 ✓, cookie ✓<br/>消耗 CPU 做 DH
R-->>I: Response
握手队列硬上限是
MAX_QUEUED_INCOMING_HANDSHAKES(4096);超过一半(2048)后入队路径会走
spin_trylock 快速失败,进一步保护内存。
Cookie 由服务器用只有自己知道的 cookie
secret 对 (发起方源 IP, 源端口) 做 MAC
计算生成。发起方必须在重试的 initiation 包中回显这个
cookie。攻击者无法伪造 cookie——即使知道 cookie
的生成算法,没有 cookie secret 也算不出有效的 MAC2。
正常用户只多一个 round trip(第一次 initiation 被拒,收到 cookie reply,第二次 initiation 带上 cookie 成功),攻击者因为没有 cookie secret 无法让服务器做 DH 运算——攻防不对称完全倒向防御方。
cookie.c 的实现只有约 150 行。cookie secret
定期轮换(cookie_checker timer),防止 secret
泄露导致历史 cookie 被回溯利用。
六、三密钥对轮换
每个 wg_peer 维护三个 keypair 槽位:
struct wg_peer {
struct noise_keypairs {
struct noise_keypair *current_keypair; // 当前用于发送的 keypair
struct noise_keypair *previous_keypair; // 上一个 keypair(仍可用于接收)
struct noise_keypair *next_keypair; // 正在握手中的新 keypair(尚未激活)
} keypairs;
};生命周期:
needs rekey → 创建 next_keypair → 握手 → 成功:
previous_keypair = current_keypair
current_keypair = next_keypair
next_keypair = NULL
所有 previous_keypair 在 REJECT_AFTER_TIME 到期后密钥材料清零
rekey 的触发条件(send.c 的
keep_key_fresh(),messages.h
常量):
- rekey-after-time:发起方 keypair
存活超过
REKEY_AFTER_TIME(120s) - rekey-after-messages:发送计数超过
REKEY_AFTER_MESSAGES(\(2^{60}\),现实中几乎不会触发) - timer_new_handshake:发送后
KEEPALIVE_TIMEOUT + REKEY_TIMEOUT(15s)内收不到任何认证包
wg set 可配置
persistent-keepalive 和
fwmark;内核没有 rekey-after-bytes
这类按流量阈值 rekey 的选项。
握手成功后,keypair 通过 RCU
指针切换:rcu_assign_pointer() 更新
current_keypair,数据路径用
rcu_dereference()
读取,握手与收发无需额外互斥锁。
WireGuard 不使用 ioctl、sysfs、procfs,也没有自定义 socket 族——它使用 Generic Netlink 作为唯一的配置接口。
Generic Netlink 是 Linux netlink
协议的”多路复用”层——允许多个子系统共享同一个 netlink socket
族。WireGuard 注册的 family name 是
WG_GENL_NAME("wireguard")。
配置通过两个核心命令:
WG_CMD_GET_DEVICE:查询设备列表、peer 信息、流量统计(rx_bytes、tx_bytes、最后一次握手时间)WG_CMD_SET_DEVICE:创建/修改设备、添加/删除 peer、更新 AllowedIPs、设置 endpoint
用户态工具 wg(8)(约 2000 行
C)通过这两个命令与内核通信。wg show
的输出(peer 公钥、endpoint、allowed ips、latest
handshake、transfer)都是从 WG_CMD_GET_DEVICE
的响应中解析出来的。
这个设计比 IPsec 的 PF_KEY/PF_INET 双 socket 方案干净:只有一个控制通道、一个族名、两个命令——没有历史遗留。
八、局限与不该用的场景
WireGuard 的简洁来自于明确的功能边界。以下场景不适合用 WireGuard:
L2 桥接 / 二层隧道。 WireGuard 是纯 L3(IP)隧道。如果需要二层扩展(跨站点 VLAN、ARP 广播穿透),用 VXLAN/GRE/IPSec。这不是 WireGuard 的缺点——拒绝 L2 是为了保证 cryptokey routing 的正确性(L2 没有 IP 地址来做 AllowedIPs 绑定)。
路由与 AllowedIPs 必须自洽。 WireGuard 会随合法入站包自动学习 peer endpoint,UDP 往返路径不必完全对称。真正的运维陷阱是宿主机路由表与 AllowedIPs 脱节——内层 IP 由 AllowedIPs 决定加密 peer,与系统路由独立配置;三者不一致时会出现”能解密但到不了目的地”的现象。这不是协议禁止非对称路由,而是要求 endpoint、AllowedIPs 与宿主机路由对齐。
动态路由协议集成。 WireGuard 不内置
OSPF/BGP。可以手动在 wg0 接口上跑 FRR/Quagga
做动态路由(WireGuard 接口本身是一个合法的
net_device,可以被路由栈使用),但这不是 WireGuard
提供的功能。OpenBSD 上的 WireGuard 实现和
rdomain(4)
配合能做一些动态路由场景,但同样属于外部编排。
UDP 被封锁的环境。 WireGuard 只用
UDP。在某些企业/国家网络环境下 UDP
被深度包检测阻断。这种情况下可以用 udp2raw(UDP
over TCP 伪装)或 phantun(UDP → TCP
obfuscator),但引入了额外的用户态组件和延迟。
需要证书体系的合规环境。 某些企业合规要求使用 X.509 证书做身份管理——IT 审计要求看到证书链、CRL 分发点、OCSP 响应。WireGuard 的公钥不具备这些——如果需要,考虑 IPSec/IKEv2 + StrongSwan。
后量子安全。 WireGuard 基于
Curve25519(椭圆曲线 DH),不抗 Shor 算法。psk
模式提供一定的过渡抗性,但不是长期方案。NIST
后量子算法标准敲定后,WireGuard
可能需要一个”PQ-WireGuard”变体——目前已有实验性实现(如
wireguard-pq),但尚未进入主线。
九、交叉影响与生态
WireGuard 的设计思想已经开始渗透到其他领域:
- 内核树内吸纳:Linux 5.6(2020 年 3 月)合并 WireGuard,比很多”更早的”内核 VPN 模块(IPsec 1999 年进入内核但 xfrm 一直在演变)更早地提供了”即装即用”的体验
- 形式化验证:Noise IKpsk2 已被 Tamarin prover 形式化验证(Donenfeld 自己在 2018 年 Real World Crypto 上展示了验证结果),WireGuard 是少数”内核代码跑在形式化验证过的协议上”的案例
- Go/Rust
用户态实现:
wireguard-go是官方维护的用户态实现,boringtun(Cloudflare 用 Rust 写的 WireGuard 用户态实现)是另一个生产级方案——这使得 WireGuard 不仅限于 Linux,可以嵌入任何 Go/Rust 项目 - Android/iOS/macOS/Windows 客户端:WireGuard 已经覆盖了所有主流计算平台——这个生态广度对于一个”只有一个人最初写了内核模块”的项目来说是罕见的速度
十、总结
WireGuard 用极简代码栈做到了完整 VPN 体验,靠的是系统性地拒绝传统复杂度(初合并约 4000 行,当前主线约 5000 行,仍远小于 IPSec 栈):
| 拒绝的东西 | 替代方案 | 换来了什么 |
|---|---|---|
| IKE 多轮协商 | Noise IKpsk2 固定握手 | ~5000 行 vs 300,000 行 |
| 证书体系(X.509/CRL/OCSP) | 公钥 = 身份 | 不需要 PKI 基础设施 |
| cipher suite negotiation | 固定 ChaCha20-Poly1305 + Curve25519 | 无降级攻击面 |
| SPD/SAD 分层 | cryptokey routing 单表 | 配置和运行时行为一致 |
| 动态参数协商 | 所有参数编译期固定 | 协议可形式化验证 |
| 算法敏捷性 | 单一密码学原语栈 | 没有 transition 期的安全漏洞 |
每一条”拒绝”都缩小了攻击面和代码量,但也都缩小了适用范围。如果你需要 L2 桥接、证书体系或与路由表脱节的复杂拓扑——WireGuard 不适合你,IPSec/IKEv2 是正确答案。但如果你只需要”在两个 IP 端点之间建立一条认证加密隧道”,WireGuard 证明了这件事不需要几十万行代码。
Linus Torvalds 在 2018 年 8 月 netdev 邮件列表中称其为”艺术品”(work of art),相较 OpenVPN 与 IPSec 的复杂度;代码在 2020 年 3 月随 Linux 5.6 合入主线。这句评价的分量在于:在内核领域,说一个东西”简单”通常是贬义(暗示功能不全),但 WireGuard 的简单是高质量的简单——每一行代码都在做正确的事,没有一行是在做”工程上正确但安全上无关”的事。
参考文献
论文与规范
- Donenfeld, J. A. “WireGuard: Next Generation Kernel Network Tunnel.” Proceedings of NDSS 2017. (A 级 — 协议设计定义文档)
- Dowling, B. & Paterson, K. G. “A Cryptographic Analysis of the WireGuard Protocol.” ACNS 2018. (A 级 — 形式化安全分析,确认 1.5 RTT 属性)
- Noise Protocol Framework, Revision 34, 2018.
noiseprotocol.org. (A 级 — IKpsk2 握手模式定义) - Nir, Y. & Langley, A. “ChaCha20 and Poly1305 for IETF Protocols.” RFC 7539, 2015.
源码
- Linux 内核
drivers/net/wireguard/, torvalds/linux.git;初合并提交e7096c131e51(v5.6-rc1),本文常量与路径对照 2026-06 主线。(A 级)
邮件与演讲
- Torvalds, L. netdev 邮件列表, 2018-08-02.(B 级 — “work of art” 引语出处)
- Donenfeld, J. A. “WireGuard: Fast, Modern, Secure VPN Tunnel.” netdev 0x13, 2019.
- Donenfeld, J. A. “WireGuard: Next Generation Kernel Network Tunnel.” Linux Plumbers Conference, 2018.
上一篇:RDMA 与 CXL:两条不同的绕开 CPU 路线 下一篇:HTTP/3 实战:从 QUIC 到 H3 的完整请求链路
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【网络工程】VPN 技术工程对比:IPSec vs WireGuard vs OpenVPN
VPN 是企业网络互联和远程访问的核心技术。本文从 IPSec 的 IKE/ESP/AH 协议栈、WireGuard 的 Cryptokey Routing 设计、OpenVPN 的 TLS 隧道模型,到三者在性能、安全、运维复杂度上的工程对比,系统讲解 VPN 技术的选型与部署实践。
【密码学百科】安全信道构造:Noise 协议框架与 Signal 协议
现代安全通信协议如何实现认证性、机密性与前向保密——本文深入 Noise 协议框架的握手模式记号,剖析 Signal 的 X3DH 与 Double Ratchet,以及 WireGuard 和 MLS 的设计
网络工程索引
汇总本站网络工程系列文章,覆盖分层模型、以太网、IP、TCP、DNS、TLS、HTTP/2/3、CDN、BGP 与故障诊断。
【网络工程】内核网络参数调优:sysctl 全景与实战
Linux 内核网络参数是系统网络性能的基础旋钮。本文从 /proc/sys/net/ 出发讲收发缓冲区自动调优、TCP backlog、conntrack、SYN flood 防护、TIME_WAIT 管理,并系统化展开多队列网卡 RSS、软件分发 RPS / RFS / XPS 的取舍与调优方法。