返回

WireGuard白皮书学习总结

前言

摘要

  1. WireGuard 是一种安全的网络隧道协议,工作于网络层(第三层),实现形式为 Linux 内核中的一个虚拟网络网卡。
  2. WireGuard 旨在替代 IPsec 和基于 TLS 的解决方案(如 OpenVPN),因为其更安全、性能更高且易于使用。
  3. WireGuard 虚拟隧道接口基于对等方的公钥与隧道源 IP 地址的关联
  4. WireGuard 使用基于 NoiseIK 的单次往返密钥交换,并通过一种新颖的定时器状态机机制透明地处理所有会话的创建。
  5. WireGuard 使用的短预共享静态密钥(Curve25519)用于类似 OpenSSH 的相互认证。WireGuard 除了提供强完美前向保密性外,还具备高度的身份隐藏特性,使用 ChaCha20Poly1305 签密算法保护隧道中的数据。WireGuard 的加密和认证功能,通过改进的 IP 绑定 Cookie 机制缓解拒绝服务攻击,显著优化了 IKEv2DTLSCookie 机制。
  6. WireGuard 的 Linux 实现代码量不足 4000 行,易于审计和验证。

注:

工作在第三层:代表可以解析网络层的数据包(IP包),可以分析出数据包的源目的ip。路由器就是工作在第三层的设备

Linux 内核中的虚拟网络网卡:指 Linux 中的 TUN 设备。目前 WireGuard 协议不仅是 Linux 内核实现,还有 Golang 实现,Haskell 实现等;另外还有 Android、Windows 和 Mac 等客户端的实现,仓库列表,在这些平台上也有类似于 Linux TUN 设备的实现方式

  • 在 Linux 中,TUN 设备是一种虚拟网络设备,与 TAP 设备类似,但 TUN 设备工作在网络层(Layer 3),处理的是 IP 数据包,而不是以太网帧。TUN 设备通常用于实现用户空间的网络协议栈、VPN、隧道等场景。TUN 设备在内核中创建,并通过字符设备文件(如 /dev/tun0)与用户空间程序交互
  • TUN/TAP 是内核虚拟网络设备,其中 TUN 是三层设备,TAP 是二层设备。TUN 设备的一端连接着内核的网络协议栈,另一段连接着一个应用层程序(在 wireguard 的 Golang 实现中,这个应用层程序是 wireguard-go )

单次往返密钥交换:只需要一轮交互就可以完成密钥交换

前向保密:长期使用的主密钥泄漏不会导致过去的会话密钥泄漏,WireGuard 中每隔 120s 会更换一个临时密钥(一次性的)来加密数据包

引言与动机

IPsec 执行了严格的分层(密钥交换层、传输层、加密层等等),这从网络的角度看是很不错的,但同时也加大了开发和部署的难度。

  • 部署 WireGuard 仅仅需要配置一个虚拟网卡(例如 wg0),该虚拟网卡可以通过 ipipconfig 等工具管理。配置接口的私钥(及可选的预共享对称密钥)和对等方的公钥后,隧道即可自动运行,密钥交换、建立、断开、数据传输、重连等过程对用户透明,管理员无需关注细节。

OpenVPN基于用户态 TUN/TAP 接口并使用 TLS。其至少有两个问题:ASN.1x509以及TLS相关的诸多 bug 使其安全性不容乐观。由于工作在用户空间,需在内核态与用户态之间多次复制数据,且依赖长驻守护进程,故 OpenVPN 的性能较低

  • WireGuard 工作在内核空间,密钥分发机制受 OpenSSH 启发,两个peer之间交换公钥的机制并不是固定的,支持 PGP 签名邮件、 LDAP 证书等多种方式交换公钥,公钥仅需 32 字节(Base64 编码为 44 字符),便于传输。

WireGuard 在加密算法选择上保持高度“固执”,避免协议灵活性。若底层原语存在漏洞,所有终端需强制更新。其使用 Noise 协议变体(1-RTT 密钥交换)、Curve25519(ECDH)、HKDF(密钥扩展)、ChaCha20Poly1305(认证加密)和 BLAKE2s(哈希),并通过加密 Cookie 机制防御拒绝服务攻击。WireGuard 仅支持第3层(IPv4/IPv6),简化协议设计并确保包的认证与归属。

加密密钥路由表

本章主要介绍了 WireGuard 中一些核心概念:Peer、Interface、路由表(CryptoKey Routing)、端点(Endpoint)


Peer

首先要指出 WireGuard 协议是一种 P2P 的协议,没有所谓的客户端和服务端的区分。因此,WireGuard隧道两端的机器被称为 Peer,WireGuard 使用长度为 32 B 的公钥(椭圆曲线公钥算法)唯一标识一个 Peer。这种设计形成了一种简单的映射关系:每个 Peer 的公钥对应一组允许的隧道源 IP 地址(AllowedIPs)。

Interface

为了把自己与其他 Peer 区分开,WireGuard 引入了 Interface 的概念,Interface 可以理解为自己这台机器上的那张 wg 虚拟网卡,由 私钥 和 监听的端口组成。

路由表

路由表记录了所有与自己建立隧道的 Peer 的信息,包括公钥和允许的 ip 列表(AllowedIPs)两部分,用于正确地加密、解密以及验证所有流经 wg 网卡的网络包。举例:

  1. 发送数据包:当一个向外发送的包要通过 wg 网卡时候,系统会根据目标 IP 地址查询路由表,发现其 dst ip 在 PeerA 的 AllowedIPs 中,WireGuard 会对这个包用与 PeerA 协商出的密钥(对称)加密,这样 PeerA 在收到这个包后,才能正确地解密。
  2. 接收数据包:当一个外面发送过来的包要通过 wg 网卡时候,首先用与 PeerA 协商出的密钥(对称)解密,如果解密失败,直接 drop;然后去查 src ip 在不在 Peer A 的 AllowedIPs 中,如果不在,直接 drop。

端点

Endpoint 实质上是其他 Peer 的 Interface,由公网 IP + 端口组成。其作用是让 WireGuard 能够把一个包发给正确的 Peer。

在 WireGuard 中,可以预先定义好 Peer 的 Endpoint,也可以不定义。如果不定义,WireGuard 将取下次来的加密数据包中的外层公网 IP 和端口 作为这个 Peer 的 Endpoint

这样做的好处是,WireGuard 允许 Peer 的公网 IP 发生变化(换句话说:漫游),这是很有用的,尤其在一些 nat 场景下(如大公司的公网出口等)。

安全性

  • 抗中间人攻击:攻击者可能篡改未认证的外层源 IP,但无法解密或修改有效载荷,仅能引发拒绝服务攻击(与直接丢弃数据包效果相同)。
  • 静默丢弃机制:若对等方无法解密或响应数据包,其端点信息将被快速遗忘(详见 被动保活 章节)。

技术优势

  1. 简化防火墙规则:来自 WireGuard 接口的数据包具有可靠认证的源 IP,管理员可基于此配置标准防火墙规则。
    • 例如,允许 wg0 接口源 IP 为 10.10.10.230 的流量,等同于信任对等方 gN65...z6EA
  2. 严格网络分层:WireGuard 仅支持第 3 层(IPv4/IPv6),避免传统 VPN(如 L2TP/IPsec)在混合网络设计中的复杂性。
  3. 反向路径过滤:通过全局路由表验证接收包的源 IP 是否与发送方的公钥匹配,确保双向一致性。

发送/接收流程

本章主要介绍 发送与接收 数据包的核心步骤:


Interface 设置:

网卡名 wg0
ListenPort 41414
PublicKey HIg0…8ykw

Peer 设置:

PublicKey TrMv…WXX0
AllowedIPs 10.192.122.4/32, 192.168.0.0/16
Endpoint 123.123.123.123:12345

发送流程

当本地生成或转发的数据包准备通过 WireGuard 接口(如 wg0)发送时,执行以下步骤:

  1. 明文数据包到达 wg0 网卡
  2. 目标 IP 匹配对等方:
    • 检查数据包的目标 IP(例如 192.168.87.21),通过加密密钥路由表确定对应的对等方公钥(如 TrMv...WXX0)。匹配时,对于单IP,相等;对于 CIDR,包含
    • 若无匹配对等方,丢弃数据包,并向源 IP 返回一个标准的 ICMP “no route to host” 包,同时向 User Space 发送一个 -ENOKEY 错误
  3. 加密数据包:使用 ChaCha20Poly1305 签密算法加密签名该数据包
  4. 封装协议头:在加密后的数据包前添加 WireGuard 协议头
  5. 发送 UDP 数据包:
    • 将协议头与加密数据包组合为 UDP 负载,发送至对等方的 Endpoint 123.123.123.123:12345(IP:Port)
    • Endpoint 可为预配置值,或通过最近接收的有效数据包外层源地址动态更新
    • 若 Endpoint 没有提前设置好,也没能够根据最近一次包学习到,则丢弃数据包并向源 IP 返回一个标准的 ICMP “no route to host” 包,同时向 User Space 发送一个 -EHOSTUNREACH 错误

wireguard 协议是对原始的 IP 包加密签名之后,加上 wireguard header,然后加上 udp header,最后在最外层再加上 IP header

可以看出来有内层 IP header (隧道 IP - AllowedIPs)和 外层 IP header(公网 IP - Endpoint) 两个 IP header,路由器等网络设备是通过外层 IP header 转发的。等网络包到达 wireguard 隧道的另外一端之后,内核中的网络协议栈会剥掉外层 IP header,然后交给 wireguard 协议栈。

wireguard 协议栈会对加密数据进行解密、验签等后续工作,就能看到内层的 IP 包(包括 IP header)了

接受流程

当 UDP 数据包到达 WireGuard 接口的监听端口(如 41414)时,处理流程如下:

  1. 接收 UDP 数据包:验证数据包目标端口是否为 WireGuard 接口的监听端口。
  2. 解密与认证:
    • 根据该数据包的 wireguard header,确定关联的对等方会话(如 TrMv...WXX0
    • 验证消息计数器message counter有效性后,使用对等方的接收对称密钥解密数据包。
    • 校验签名,如果校验失败,丢弃;解密数据包,如果解密失败,丢弃
  3. 更新对等方端点:认证成功后,使用 UDP 数据包的外层源 IP(公网 ip) 和端口更新 TrMv...WXX0Endpoint
  4. 验证源 IP(隧道 ip) 合法性:
    • 如果数据包(现在已经是明文的了)不是 IP 包,则丢弃
    • 解密后的明文数据包需检查其源 IP 是否属于对等方的 AllowedIPs 允许列表(如 192.168.87.21
    • 若源 IP 未授权(例如 10.192.122.3),丢弃数据包
  5. 注入接收队列:通过验证的明文数据包被插入 wg0 接口的接收队列,供上层协议处理

设计优势

  • 反向路径过滤:发送与接收共用同一路由表,确保源 IP 与目标 IP 的全局一致性。例如,接收数据包的源 IP 必须与发送时选择的对等方公钥匹配
    • 所有 dest ip192.168.87.21 的包都会被发送给 peer A
    • 如果 src ip192.168.87.21,那可以确认这个包来自 peer A
  • 高效性
    • 协议头仅 16 字节(含 3 字节保留字段),支持内存对齐操作,便于硬件加速。
    • 认证加密(AEAD)与协议处理在软中断(softirq)中完成,减少上下文切换开销。
  • 抗重放攻击:使用滑动窗口机制跟踪接收消息的随机数计数器,支持乱序数据包处理(基于 RFC6479)

实例分析发送与接收流程

客户端通过浏览器访问AllowedIPs中的某个ip

img

客户端流量走向

  1. 应用层程序 chrome 创建网络包,并发送给对应的 socket
  2. 通过对应的 socket,该网络包到达了内核协议栈。在网络协议栈会判断路由,通过路由把流量发到 wg0 网卡;
  3. 到达 wg0 网卡的包,会通过字符驱动直接发送给应用层程序 wireguard
  4. wireguard 会对网络包进行加密、签名等操作(参考上面的发送流程),之后发送给对应的 socket;此时网络包的目的 ip 已经是 vpn 服务端的公网 ip 了(Endpoint)
  5. socket 发出的包会通过内核网络协议栈,在内核网络协议栈会判断路由,通过路由把流量发到 eth0 网卡;
  6. 网络包通过 eth0 发送出去

wireguard 工作在第三层,即网络层,这意味着当应用层产生了一个数据包,经过路由表的决策到达 wg0 网卡的时候,wireguard 看到的是 IP Header + IP Data,那么 wireguard 就可以对 IP Header + IP Data 签名加密。这是很重要的一点,因为这保证了 IP Header 中的 src addr 和 dest addr 是可信的、不能被篡改的。

只有这样,wireguard 才能保证 allowed ip 列表是可信的,进而保证如下几点:

  1. 发给 peer a 的包不会发送给 peer b
  2. 可以解密别的 peer 发给自己的包,而别人无法解密
  3. 收到一个包时,根据 src ip 就可以知道这个包来自于哪个 peer

服务端(中继服务器)网络包的流向如下

img

服务端(中继服务器)流量走向

  1. 网络包通过公网,到达 eth0 网卡
  2. 由于目标端口是被 wireguard 监听的,所以网络包发送到对应的 socket
  3. socket 将网络包发送给应用层程序 wireguard
  4. wireguard 会对网络包进行解密、验证签名等操作(参考上面章节的接受流程)之后,通过字符驱动发送给对应的虚拟网卡 wg0;(此时网络包的目的 ip 已经是AllowedIPs中的某个ip了)(中继服务器只做转发)
  5. wg0 发出的包会通过内核网络协议栈,在内核网络协议栈会判断路由,通过路由把流量发到 eth0 网卡;
  6. 网络包通过 eth0 发送出去

基本用法

本章介绍了 wireguard 的基础用法,为下一章中介绍密码学细节和实现细节打好基础。


环境

  • linux 环境
  • 物理网卡 eth0,该网卡可以访问互联网,公网 IP 为 192.95.5.69
  • wireguard 虚拟网卡 wg0,IP 为 10.192.122.3/24
  • 通过 wg0 接口路由 10.0.0.0/8 网段流量

配置流程

首先建立 wg0 网卡,然后为该网卡设置 IP 地址,并添加一条路由:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 添加 WireGuard 虚拟接口
$ ip link add dev wg0 type wireguard

# 为接口分配 IP 地址
$ ip address add dev wg0 10.192.122.3/24

# 添加路由规则,将 10.0.0.0/8 网段流量定向至 wg0
$ ip route add 10.0.0.0/8 dev wg0

# 查看接口配置
$ ip address show
1: lo: <LOOPBACK> mtu 65536 
	inet 127.0.0.1/8 scope host lo
2: eth0: <BROADCAST> mtu 1500 
	inet 192.95.5.69/24 scope global eth0
3: wg0: <POINTOPOINT,NOARP> mtu 1420 
	inet 10.192.122.3/24 scope global wg0

设置加密路由表(wireguard 中的路由表的概念参考前文),并拉起 wg0 网卡:

1
2
3
4
5
6
7
8
9
$ wg set conf wg0 configuration-1.conf
$ wg show wg0
interface: wg0
    public key: HIgo...8ykw
    listening port: 41414
peer: gN65...z6EA
    allowed ips: 10.10.10.230/32
    endpoint: 192.95.5.70:54421
$ ip link set wg0 up

现在,这台机器上目标地址为 10.10.10.230/32 的包会被发送到wg0网卡(笔者注:还记得前面加了一条 10.0.0.0/8 的路由吗),wg0 网卡会签名加密这个包(使用 gN65…z6EA 对应的 session),并将这个经过签名加密的包(使用udp)发送给 192.95.5.70:54421

如果这台机器的 wg0 网卡收到了源 IP 为 10.10.10.230 的包,可以确认这个包来自 peer gN65…z6EA

协议与加密

本章介绍 wireguard 协议的细节,以及其涉及到的密码学知识

WireGuard 的安全通信建立于 1-RTT 握手协议,基于 NoiseIK 模式实现密钥交换,并结合多项创新机制抵御攻击。本章详细阐述其协议设计、加密算法及安全特性。

沉默是金

介绍了 wireguard 的一个重要的设计原则:尽量不回复未经过认证的包

wireguard 的一个设计目标是在完成对数据包的认证之前不存储任何的状态,也不回复这个数据包。这样做的好处是:

  1. 对于非法的 peers(预期内不会与其建立连接的 peer) 和 网络扫描器是不可见的
  2. 避免了一些攻击(如 DoS 攻击)
  3. 只有这样,才能做到在处理数据包的时候不需要额外的内存(注:这是很重要的,是 wireguard 被纳入 linux 内核的前提)

为了实现上面的这个设计原则,responder 必须要对 Handshake Initiation 进行认证,这带来了新的问题:重放攻击

  1. 攻击者截获到 Handshake Initiation 数据包,将这个包重放给 responder
  2. responder 收到这个包之后,会认为 initiator 希望重新建立隧道,因此重新生成一个加密 session(注:包含临时公钥、临时密钥、KDF 状态等,后面会详细介绍);
  3. 由于 initiator 不知道 responder 已经修改了加密 session,所以其之后发送的所有的包都会被认为是非法的,现在 initiatorresponder 之前建立隧道事实上已经断开了。

为了解决这个问题,initiatorHandshake Initiation 包中加入了时间戳,responder 会记录收到的最大时间戳。并丢弃所有 initiator 发来的、时间戳小于等于最大时间戳的所有的 Handshake Initiation 数据包。

如果 responder 重启,会发生什么事情呢?

  1. 由于之前和 initiator 建立的隧道已经断开了,此时攻击者重放的 Handshake Initiation 包是会被 responder 接受的,但攻击者也只能做到这一步了,它无法发送数据给 responder
  2. 等到 initiator 感知到隧道已经断开之后,会尝试重新建立隧道。所以在这种情况下,攻击者也是无法进行重放攻击的。

这种机制也隐含着一个 wireguard 使用原则:永远不要为两个 peer 设置同一个私钥。否则,这两个 peer 会互相破坏对方建立的加密 session

可选预共享对称密钥模式

介绍了 wireguard 是如何抵抗量子计算的

为应对量子计算威胁,WireGuard 支持在 ECDH 基础上叠加 256 位预共享对称密钥(PSK)

  1. 混合加密
    • PSK 与 Curve25519 密钥联合保护数据,即使未来 ECDH 被量子计算攻破,PSK 仍可确保历史数据安全。
    • PSK 泄露不影响当前通信安全(依赖 ECDH 保护),但需定期轮换以实现后量子安全。
  2. 密钥管理
    • PSK 需通过带外(OOB)安全渠道预共享,例如物理交换或量子安全信道。

介绍了 wireguard 的 cookie 机制是如何缓解 DoS 攻击的

responder 收到 initiator 发来的 Handshake Initiation 之后,为了校验 Handshake Initiation,需要计算一次 Curve25519 椭圆曲线乘法。这种机制可能会导致一种 DoS 攻击,具体来说:攻击者不断地向目标发送 Handshake Initiation,直至耗尽攻击目标的 CPU 资源,使其无法正常提供服务。

wireguard 协议规定,为了缓解 DoS 攻击,当 responder 过载时,可以选择不处理 Handshake Initiation 数据包,而是返回一个 Cookie Reply Messageinitiator 需要保存下这个 Cookie,并根据这个 Cookie 中的信息决定什么时机重发 Handshake Initiation 数据包

过载状态

wireguard-go 中,判断当前是否为过载状态的逻辑在 device.go 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const (
	QueueHandshakeSize = 1024
	UnderLoadAfterTime = time.Second // how long does the device remain under load after detected   
)

func (device *Device) IsUnderLoad() bool {
	// check if currently under load
	now := time.Now()
	underLoad := len(device.queue.handshake.c) >= QueueHandshakeSize/8
	if underLoad {
		atomic.StoreInt64(&device.rate.underLoadUntil, now.Add(UnderLoadAfterTime).UnixNano())
		return true
	}
	// check if recently under load
	return atomic.LoadInt64(&device.rate.underLoadUntil) > now.UnixNano()
}

上面的代码逻辑为:如果当前待处理的 Handshake 包(笔者注:包含 Handshake Initiation、Handshake Response 和 Cookie Reply Message 三种)的数量超过 128 个,则进入过载状态,持续 1 s

消息格式

介绍了 wireguard 协议中的各种包

下面先介绍 wireguard 用到的密码学原语

notation

首先介绍一些符号,这些符号会在后面中使用到:

  • initiator 是发起者,responder 是响应者

注:前面说过 wireguard 是一种 p2p 协议,没有服务端和客户端的区别,所以白皮书中使用 initiatorresponder 代表协议的两个参与方,initiator 是发起协商流程的一方,而 response 是响应协商流程的一方;

  • $S_i^{priv}$ 是 initiator 的永久私钥,$S_i^{pub}$ 是 initiator 的永久公钥,$E_i^{priv}$ 是 initiator 的临时私钥,$E_i^{pub}$ 是 initiator 的临时公钥

注:S - StaticE - ephemeralpriv - private keypub - public key

  • 上面的底标 i 表示 initiator,同样的,底标为 r 的时候表示 responder 的永久私钥、永久公钥、临时私钥、临时公钥
  • || 表示将两个字符串连接在一起
  • := 表示将右边的值赋值给左边

Elliptic-curve

wireguard 中使用椭圆曲线算法生成密钥对,具体为:

  • $S_i^{pub}=S_i^{priv}*G$
  • $S_r^{pub}=S_r^{priv}*G$
  • $E_i^{pub}=E_i^{priv}*G$
  • $E_r^{pub}=E_r^{priv}*G$

其中 $S_i^{priv}$ 、 $S_r^{priv}$ 、 $E_i^{priv}$ 、 $E_r^{priv}$ 这四个私钥是随机生成的,且仅生成方知道,椭圆曲线上的离散对数难题保证了其他人无法通过公钥推导出私钥。

Elliptic-curve Diffie-Hellman

ECDH 是一种密钥交换算法,允许协议双方在不安全的网络中协商出一个共享密钥。wireguard 中密钥协商的步骤如下:

  1. initiator 生成一对临时的椭圆曲线密钥 $E_i^{pirv}:=random, E_i^{pub}=E_i^{priv}*G $
  2. initiator 计算出共享密钥 $ss_1=E_i^{priv}*S_r^{pub}$
  3. initiator 将 $E_i^{pub}$ 发送给 responder
  4. responder 计算出共享密钥 $ss_2=S_r^{priv}*E_i^{pub}$
  5. $ss_1=E_i^{priv}S_r^{pub}=E_i^{priv}(S_r^{priv}G)=S_r^{priv}(E_i^{priv}*G)=S_r^{priv}*E_i^{pub}=ss_2$

注:* 为椭圆曲线乘法, G 为椭圆曲线单位点

wireguard 中使用到相关原语有两个:

  • DH(Point 1, Point 2) 代表椭圆曲线乘法,返回一个长度为 32 的字节数组,在 wireguard-go中,代码实现如下:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import (
	"golang.org/x/crypto/curve25519"
)

func (sk *NoisePrivateKey) sharedSecret(pk NoisePublicKey) (ss [NoisePublicKeySize]byte) {
	apk := (*[NoisePublicKeySize]byte)(&pk)
	ask := (*[NoisePrivateKeySize]byte)(sk)
	curve25519.ScalarMult(&ss, ask, apk)
	return ss
}
  • DH-GENERATE() 代表生成一对密钥对,实现的原理为 $pk = sk*G$ ,在 wireguard-go 中,代码实现为:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import (
	"golang.org/x/crypto/curve25519"
)

func (sk *NoisePrivateKey) publicKey() (pk NoisePublicKey) {
	apk := (*[NoisePublicKeySize]byte)(&pk)
	ask := (*[NoisePrivateKeySize]byte)(sk)
	curve25519.ScalarBaseMult(apk, ask)
	return
}

AEAD & XAEAD

带有关联数据的认证加密算法 Authenticated Encryption with Associated Data (AEAD) 用于加密数据,并对额外的一段数据进行签名,用于证明发送者的身份。wireguard 中使用到了 ChaCha20Poly1305 AEAD 算法,代码实现为:

注:ChaCha20Poly1305具体的原理参考:rfc7539

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import (
	"golang.org/x/crypto/chacha20poly1305"
)

// 加密+签名
aead, _ := chacha20poly1305.New(key[:])
aead.Seal(msg.Static[:0], ZeroNonce[:], device.staticIdentity.publicKey[:], handshake.hash[:])

// 验签+解密
aead, _ := chacha20poly1305.New(key[:])
_, err = aead.Open(peerPK[:0], ZeroNonce[:], msg.Static[:], hash[:])

XAEAD 是指带 nonceAEAD 算法,wireguard 中使用了 XChaCha20-Poly1305 算法,代码实现为:

1
2
3
4
5
6
7
// 加密+签名
xchapoly, _ := chacha20poly1305.NewX(st.mac2.encryptionKey[:])
xchapoly.Seal(reply.Cookie[:0], reply.Nonce[:], cookie[:], msg[smac1:smac2])

// 验签+解密
xchapoly, _ := chacha20poly1305.NewX(st.mac2.encryptionKey[:])
_, err := xchapoly.Open(cookie[:0], msg.Nonce[:], msg.Cookie[:], st.mac2.lastMAC1[:])

Hash

哈希算法用于计算一段消息的哈希值,wireguard 使用到了 BLAKE2s 哈希算法,代码实现为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import (
	"golang.org/x/crypto/blake2s"
)

func mixHash(dst, h *[blake2s.Size]byte, data []byte) {
	hash, _ := blake2s.New256(nil)
	hash.Write(h[:])
	hash.Write(data)
	hash.Sum(dst[:0])
	hash.Reset()
}

MAC

消息认证码算法 Message Authentication Code (MAC) 用于生成一段消息的标签,wireguard 使用到了基于 BLAKE2s 哈希算法的MAC,代码实现为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import (
	"crypto/hmac"
	"hash"
	"golang.org/x/crypto/blake2s"
)

func HMAC1(sum *[blake2s.Size]byte, key, in0 []byte) {
	mac := hmac.New(func() hash.Hash {
		h, _ := blake2s.New256(nil)
		return h
	}, key)
	mac.Write(in0)
	mac.Sum(sum[:0])
}

func HMAC2(sum *[blake2s.Size]byte, key, in0, in1 []byte) {
	mac := hmac.New(func() hash.Hash {
		h, _ := blake2s.New256(nil)
		return h
	}, key)
	mac.Write(in0)
	mac.Write(in1)
	mac.Sum(sum[:0])
}

KDF

密钥生成函数 Key Derivation Function (KDF) 用于密钥生成,wireguard 中使用到了 HMAC-based Key Derivation Function (HKDF)算法(基于HMAC的密钥推导函数),基本原理为:

  1. $\tau_0:=HMAC(KEY,INPUT)$
  2. $\tau_1:=HMAC(\tau_0,1)$
  3. $\tau_2:=HMAC(\tau_0,\tau_1||2)$
  4. $\tau_3:=HMAC(\tau_0,\tau_2||3)$
  5. $\tau_i:=HMAC(\tau_0,\tau_{i-1}||i)$

注:具体的原理参考 rfc5869

代码实现为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func KDF1(t0 *[blake2s.Size]byte, key, input []byte) {
	HMAC1(t0, key, input)
	HMAC1(t0, t0[:], []byte{0x1})
}

func KDF2(t0, t1 *[blake2s.Size]byte, key, input []byte) {
	var prk [blake2s.Size]byte
	HMAC1(&prk, key, input)
	HMAC1(t0, prk[:], []byte{0x1})
	HMAC2(t1, prk[:], t0[:], []byte{0x2})
	setZero(prk[:])
}

func KDF3(t0, t1, t2 *[blake2s.Size]byte, key, input []byte) {
	var prk [blake2s.Size]byte
	HMAC1(&prk, key, input)
	HMAC1(t0, prk[:], []byte{0x1})
	HMAC2(t1, prk[:], t0[:], []byte{0x2})
	HMAC2(t2, prk[:], t1[:], []byte{0x3})
	setZero(prk[:])
}

协议概述

第一种

在大多数情况下,只需要 1-RTT 即可完成握手流程,然后协议双方就可以相互传输数据了

第二种

当协议双方中的任意一方负载过大的时候,会引入 cookie reply 数据包,比如下图中展示了 responder 负载过大时,会回复一个 cookie reply 数据包,用于防止 DoS 攻击

  • 发起方收到 Cookie 后,会发送一个携带 Cookie 的握手请求

TCP 协议中的类似设计

为什么需要考虑负载过大的情况:在收到 handshake initiation 包之后,responder 为了确定握手包是否合法,至少需要做一次 Curve25519 乘法操作。而计算 Curve25519 乘法是 CPU 密集型的操作,这意味着攻击者可以不断地向某个 peer 发送 handshake initiation 包,直到耗尽该 peerCPU,导致其无法和其他 peer 正常建立隧道

TCP 也有类似的攻击方式 SYN Flood,也存在一种类似的解决方案 SYN_cookies

Handshake Initiation

initiator 想要和 responder 建立隧道的时候,initiator 需要发送一个 Handshake Initiation,其数据包结构如下:

可以借助 wireshark 直观地看到 Handshake Initiation 包的结构:

可以注意到 wireshark 抓包的长度为 148 B,这个值和上一张图中 1 + 3 + 4 + 32 + 32 + 12 + 16 + 16 = 116 B 对不上。这是因为 statictimestamp 上面有一个小尖号,这个小尖号代表长度为n + 16,多出来的 16Poly1305 authentication tag 的长度

wireguard-go 中,Handshake Initiation 的定义在 device/noise-protocol.go 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const (
	NoisePublicKeySize    = 32
)

type (
	NoisePublicKey    [NoisePublicKeySize]byte
)

type MessageInitiation struct {
	Type      uint32
	Sender    uint32
	Ephemeral NoisePublicKey
	Static    [NoisePublicKeySize + poly1305.TagSize]byte
	Timestamp [tai64n.TimestampSize + poly1305.TagSize]byte
	MAC1      [blake2s.Size128]byte
	MAC2      [blake2s.Size128]byte
}

wireguard-go 中,构造包的逻辑在 device/noise-protocol.go 的函数 CreateMessageInitiation 中:

type 字段

type 字段用于指示该包的类型,其定义如下:

1
2
3
4
5
6
const (
	MessageInitiationType  = 1
	MessageResponseType    = 2
	MessageCookieReplyType = 3
	MessageTransportType   = 4
)

reserved 字段

保留字段,固定为零

sender 字段

随机生成一个长度为 4 的字节数组,用于唯一标识该包的发送者。在 wireguard-go 的实现如下,引入了索引表 IndexTable 来维护所有的 Peersender,方便快速地通过 sender 找到对应的 peer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func randUint32() (uint32, error) {
	var integer [4]byte
	_, err := rand.Read(integer[:])
	// Arbitrary endianness; both are intrinsified by the Go compiler.
	return binary.LittleEndian.Uint32(integer[:]), err
}

func (table *IndexTable) NewIndexForHandshake(peer *Peer, handshake *Handshake) (uint32, error) {
	for {
		// generate random index

		index, err := randUint32()
		if err != nil {
			return index, err
		}

		// check if index used

		// check again while locked
	}
}

msg.Sender, err = device.indexTable.NewIndexForHandshake(peer, handshake)

ephemeral 字段

临时公钥,长度为 32 的字节数组,Curve25519

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
handshake := &peer.handshake

// create ephemeral key
var err error
handshake.localEphemeral, err = newPrivateKey()

msg := MessageInitiation{
	Type:      MessageInitiationType,
	Ephemeral: handshake.localEphemeral.publicKey(),
}

static 字段

永久公钥,长度为 32 + 16 bytes,其中的 32 bytes 是公钥的长度,16 bytesPoly1305authentication tag 的长度。

为了保密,不可以明文传输公钥;另外,为了证明发送者的身份,必须要加上消息认证算法。wireguard 使用的是 ChaCha20Poly1305 AEAD 算法,其中 ChaCha20 为一种流加密算法,其加密得到的密文的长度等于明文的长度;Poly1305 是一种 MAC 算法,其计算得到的 authentication tag 长度为 16 bytes

ChaCha20Poly1305 算法的基本原理为首先使用 ChaCha20 对明文加密(对称加密),然后使用 Poly1305 计算出 authentication tag,并将其加到密文的后面,所以最后输出的长度为 32 + 16

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// encrypt static key
ss := handshake.localEphemeral.sharedSecret(handshake.remoteStatic)
if isZero(ss[:]) {
	return nil, errZeroECDHResult
}

var key [chacha20poly1305.KeySize]byte
KDF2(
	&handshake.chainKey,
	&key,
	handshake.chainKey[:],
	ss[:],
)

aead, _ := chacha20poly1305.New(key[:])
aead.Seal(msg.Static[:0], ZeroNonce[:], device.staticIdentity.publicKey[:], handshake.hash[:])

timestamp 字段

时间戳,经过 AEAD 算法的加密输出,原理和 static 字段相同

1
2
3
timestamp := tai64n.Now()
aead, _ = chacha20poly1305.New(key[:])
aead.Seal(msg.Timestamp[:0], ZeroNonce[:], timestamp[:], handshake.hash[:])

mac1 和 mac2 字段

双重消息认证码

Handshake Response

responder 接受到 initiator 发送的 Handshake Initiation 之后,会生成并返回一个 Handshake Response 数据包,其数据包结构如下:

可以借助 wireshark 直观地看到 Handshake Response 包的结构:

wireshark 抓包的长度为 92 B,这个值和上一张图中 1 + 3 + 4 + 4 + 32 + 0 + 16 + 16 = 76 B 对不上。这是因为 empty 上面有一个小尖号,这个小尖号代表长度为n + 16,多出来的 16Poly1305 authentication tag 的长度

wireguard-go 中,Handshake Response 的定义在 device/noise-protocol.go 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
const (
	NoisePublicKeySize    = 32
)

type (
	NoisePublicKey    [NoisePublicKeySize]byte
)

type MessageResponse struct {
	Type      uint32
	Sender    uint32
	Receiver  uint32
	Ephemeral NoisePublicKey
	Empty     [poly1305.TagSize]byte
	MAC1      [blake2s.Size128]byte
	MAC2      [blake2s.Size128]byte
}

wireguard-go 中,构造包的逻辑在 device/noise-protocol.goCreateMessageResponse 函数中

type 字段

type 字段用于指示该包的类型,在 Handshake Response 包中,该字段固定为 2

sender 字段

随机生成的长度为 32 的字节数组,用于唯一标识该包的发送者

1
2
3
4
device.indexTable.Delete(handshake.localIndex)
handshake.localIndex, err = device.indexTable.NewIndexForHandshake(peer, handshake)

msg.Sender = handshake.localIndex

receiver 字段

该字段表示该包的接收者:

1
2
handshake := &peer.handshake
msg.Receiver = handshake.remoteIndex

其中 handshake.remoteIndex 是在处理 Handshake Initiation 包时候赋值的,代码在函数 ConsumeMessageInitiation中:

1
handshake.remoteIndex = msg.Sender

ephemeral 字段

临时公钥,长度为 32 的字节数组。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (sk *NoisePrivateKey) clamp() {
	sk[0] &= 248
	sk[31] = (sk[31] & 127) | 64
}

func newPrivateKey() (sk NoisePrivateKey, err error) {
	_, err = rand.Read(sk[:])
	sk.clamp()
	return
}

handshake.localEphemeral, err = newPrivateKey()
msg.Ephemeral = handshake.localEphemeral.publicKey()

empty 字段

长度为 16 字节,该字段是对 12 字节的全零数据计算 Poly1035authentication tag

1
2
3
4
5
func() {
   aead, _ := chacha20poly1305.New(key[:])
   aead.Seal(msg.Empty[:0], ZeroNonce[:], nil, handshake.hash[:])
   handshake.mixHash(msg.Empty[:])
}()

mac1 和 mac2 字段

双重消息认证码

放大攻击

放大攻击是指攻击者通过发送小型请求来导致大规模响应,达到四两拨千斤的效果。常见放大攻击方式为DNS 放大攻击

针对 wireguard 协议,一种可能的攻击方式是攻击者从网络链路中获取到攻击目标发送给 wireguard server 的 Handshake Initiation 数据包。不断地给多个 wireguard server 重放这个数据包,wireguard server 会根据 wireguard 协议生成 Handshake Response 并发送给攻击目标,从而消耗掉攻击目标的网络流量。

在 wireguard 协议中, Handshake Response 包比 Handshake Initiation 数据包小,这意味着攻击者要消耗掉目标 1 G 的流量,必须消耗掉大于 1 G 的流量,这从根本上杜绝了放大攻击。

WireGuard 通过加密 Cookie 抵御 CPU 资源耗尽攻击,其机制优于 DTLS 和 IKEv2:

  1. Cookie 生成
    • 接收方每 2 分钟更换一次 秘密随机值,并基于此计算 Cookie:$Cookie=MAC(秘密随机值,源 IP/端口)$
  2. 握手消息处理
    • 所有握手消息携带 双重 MACmsg.mac1msg.mac2):
      • msg.mac1:使用接收方公钥生成,验证对等方身份。
      • msg.mac2:使用 Cookie 生成,验证 IP 所有权(仅在负载高时检查)。
  3. 抗中间人攻击
    • Cookie 通过 XChaCha20Poly1305 AEAD 加密传输,绑定到特定握手消息,阻止攻击者伪造 Cookie 耗尽对等方资源。

Transport Data Key Derivation

本节介绍握手流程完成之后,initiatorresponder 如何协商出用于加密传输数据的对称密钥

wg握手过程中有四个状态点:

  • 状态 1:initiator 创建了 Handshake Initiation 数据包之后;在 wireguard-go 中,该状态名称为 handshakeInitiationCreated
  • 状态 2:responder 接收到并消费了 Handshake Initiation 数据包之后;在 wireguard-go 中,该状态名称为 handshakeInitiationConsumed
  • 状态 3:responder 创建了 Handshake Response 数据包之后;在 wireguard-go 中,该状态名称为 handshakeResponseCreated
  • 状态 4:initiator 接收到并消费了 Handshake Response 数据包之后;在 wireguard-go 中,该状态名称为 handshakeResponseConsumed

其中,当 responder 处于状态 3 时,会计算传输密钥(计算密钥的过程中使用到的 KDF 在前文消息格式开头有介绍):$T_r^{send},T_r^{recv}=KDF_2(C_r,nil)$

initiator 处于状态 4 的时候,会计算传输密钥: $T_i^{recv},T_i^{send}=KDF_2(C_i,nil)$

wireguard-go 中,代码实现在 noise-protocol.goBeginSymmetricSession 函数中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
if handshake.state == handshakeResponseConsumed {
   KDF2(
      &sendKey,
      &recvKey,
      handshake.chainKey[:],
      nil,
   )
   isInitiator = true
} else if handshake.state == handshakeResponseCreated {
   KDF2(
      &recvKey,
      &sendKey,
      handshake.chainKey[:],
      nil,
   )
   isInitiator = false
} else {
   return fmt.Errorf("invalid state for keypair derivation: %v", handshake.state)
}

可以看出来,要保证 $T_r^{send}=T_i^{recv},T_r^{recv}=T_i^{send}$ ,需要保证 $C_r=C_i$ 。下面看下 $C_r$ 和 $C_i$ 的计算过程:

如下的几点共同保证了 $C_r=C_i$ :

  • CONSTRUCTION 是一个固定的字符串 Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s
1
2
3
const (
	NoiseConstruction = "Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s"
)

如下的几点共同保证了 $C_r=C_i$ :

  • 根据 ECC 原理:$E_i^{priv}*S_r^{pub}=E_i^{priv}*S_r^{priv}*G=S_r^{priv}*E_i^{priv}*G=S_r^{priv}*E_i^{pub}$
  • 根据 ECC 原理:$S_i^{priv}*S_r^{pub}=S_i^{priv}*S_r^{priv}*G=S_r^{priv}*S_i^{priv}*G=S_r^{priv}*S_i^{pub}$
  • 根据 ECC 原理:$E_r^{priv}*E_i^{pub}=E_r^{priv}*E_i^{priv}*G=E_i^{priv}*E_r^{priv}*G=E_i^{priv}*E_r^{pub}$
  • 根据 ECC 原理:$E_r^{priv}*S_i^{pub}=E_r^{priv}*S_i^{priv}*G=S_i^{priv}*E_r^{priv}*G=S_i^{priv}*E_r^{pub}$

Transport Data Messages

可以借助 wireshark 直观地看到 Transport Data Messages 包的结构:

type 字段

type 字段用于指示该包的类型,在 Transport Data Messages 包中,该字段固定为 4。

reserved 字段

保留字段,该字段至少有如下两个作用:

  1. 程序可以方便地以小端整数读出 type 字段
  2. 保证了 header 的长度为 16 bytes,这意味着对于大多数的CPU架构,内存地址是对齐的,这会使赋值和加解密操作更加容易、性能更高。

receiver 字段

这里引入了新的 notation $m$ 和 $m'$ ,含义为:如果该包是 initiator 创建的,则 $m=i,m'=r$ ;如果该包是 responder 创建的,则 $m=r,m'=i$ 。在wireguard-go 中,其代码实现在 send.go 的函数 RoutineEncryption 中:

1
2
3
4
5
6
7
8
9
const (
   MessageTransportHeaderSize = 16   // size of data preceding content in transport message
)

header := elem.buffer[:MessageTransportHeaderSize]

fieldReceiver := header[4:8]

binary.LittleEndian.PutUint32(fieldReceiver, elem.keypair.remoteIndex)

counter 字段

该字段有两个作用:

  1. 对于发送方,用于 ChaCha20Poly1305 签密算法的 nonce(Number Once)
  2. 对于接收方,用于记录发送方的最大 nonce,防止重放攻击
1
2
3
4
5
6
7
8
9
const (
   MessageTransportHeaderSize = 16   // size of data preceding content in transport message
)

header := elem.buffer[:MessageTransportHeaderSize]

fieldNonce := header[8:16]

binary.LittleEndian.PutUint64(fieldNonce, elem.nonce)

packet 字段

使用 $P$ 代表要发送的明文数据,使用 $\vert \vert P \vert \vert$ 代表 $P$ 的长度,使用 $\widehat{\vert \vert P \vert \vert}$ 代表 $\vert \vert P \vert \vert + 16$ ,其中 16 是 Poly1305 的 authentication tag 的长度

需要注意的是,明文数据的长度是无法保证的,所以 wireguard 中会对其进行补齐,代码实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func calculatePaddingSize(packetSize, mtu int) int {
   lastUnit := packetSize
   if mtu == 0 {
      return ((lastUnit + PaddingMultiple - 1) & ^(PaddingMultiple - 1)) - lastUnit
   }
   if lastUnit > mtu {
      lastUnit %= mtu
   }
   paddedSize := ((lastUnit + PaddingMultiple - 1) & ^(PaddingMultiple - 1))
   if paddedSize > mtu {
      paddedSize = mtu
   }
   return paddedSize - lastUnit
}

// pad content to multiple of 16
paddingSize := calculatePaddingSize(len(elem.packet), int(atomic.LoadInt32(&device.tun.mtu)))
elem.packet = append(elem.packet, paddingZeros[:paddingSize]...)

签密的实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var nonce [chacha20poly1305.NonceSize]byte

binary.LittleEndian.PutUint64(nonce[4:], elem.nonce)

elem.packet = elem.keypair.send.Seal(
   header,
   nonce[:],
   elem.packet,
   nil,
)

responder 接收到 initiator 发送的 Handshake Initiation 之后,如果此时处于过载状态,会生成并返回一个 Cookie Reply Message 数据包,其数据包结构如下:

wireguard-go 中,代码实现为:

1
2
3
4
5
6
type MessageCookieReply struct {
	Type     uint32
	Receiver uint32
	Nonce    [chacha20poly1305.NonceSizeX]byte
	Cookie   [blake2s.Size128 + poly1305.TagSize]byte
}

type 字段

type 字段用于指示该包的类型,在 Cookie Reply Message 包中,该字段固定为 3

reserved 字段

保留字段,该字段至少有如下两个作用:

  1. 程序可以方便地以小端整数读出 type 字段
  2. 保证了 header 的长度为 8 bytes 的倍数,这意味着对于大多数的CPU架构,内存地址是对齐的,这会使赋值和加解密操作更加容易、性能更高。

receiver 字段

该包的接收者,也就是 Handshake Initiation 数据包中 Sender 字段。

nonce 字段

随机 nonce,代码实现为(代码位置在 cookie.goCreateReply 函数中,下同):

1
2
3
4
5
6
7
8
9
reply := new(MessageCookieReply)
reply.Type = MessageCookieReplyType
reply.Receiver = recv

_, err := rand.Read(reply.Nonce[:])
if err != nil {
	st.RUnlock()
	return nil, err
}

cookie 字段

cookie,计算逻辑为:

$\tau := MAC(R_m,A_{m'})$

$msg.cookie := XAEAD(HASH(LABEL-COOKIE || S_m^{pub}),msg.nonce,\tau,M)$

其中 MAC 代表带密钥的 BLAKE2s 哈希算法; $R_m$ 代表密钥,该密钥每 2 分钟更换一次,代码实现为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if time.Since(st.mac2.secretSet) > CookieRefreshTime {
	st.RUnlock()
	st.Lock()
	_, err := rand.Read(st.mac2.secret[:])
	if err != nil {
		st.Unlock()
		return nil, err
	}
	st.mac2.secretSet = time.Now()
	st.Unlock()
	st.RLock()
}

$A_{m'}$ 表示接收者的endpoint;计算 $\tau$ 的代码实现为:

1
2
3
4
5
6
var cookie [blake2s.Size128]byte
func() {
	mac, _ := blake2s.New128(st.mac2.secret[:])
	mac.Write(src)
	mac.Sum(cookie[:0])
}()

XAEADChaCha20Poly1305 签密算法,LABEL-COOKIE 是固定的字符串, $S_m^{pub}$ 代表 responder 的公钥,代码实现为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
const (
	WGLabelCookie     = "cookie--"
)

func() {
	hash, _ := blake2s.New256(nil)
	hash.Write([]byte(WGLabelCookie))
	hash.Write(pk[:])
	hash.Sum(st.mac2.encryptionKey[:0])
}()

xchapoly, _ := chacha20poly1305.NewX(st.mac2.encryptionKey[:])
xchapoly.Seal(reply.Cookie[:0], reply.Nonce[:], cookie[:], msg[smac1:smac2])

M 代表 Handshake Initiation 数据包中的 mac1 字段,代码实现为:

1
2
3
4
5
6
size := len(msg)
smac2 := size - blake2s.Size128
smac1 := smac2 - blake2s.Size128

xchapoly, _ := chacha20poly1305.NewX(st.mac2.encryptionKey[:])
xchapoly.Seal(reply.Cookie[:0], reply.Nonce[:], cookie[:], msg[smac1:smac2])

攻击方式

  • 重放攻击:攻击者可以截获 responder 发送给 initiatorCookie Reply Message 数据包,然后截获信道中的其它 Handshake Initiation 数据包,重放 Cookie Reply Messageinitiator
    • 由于 Cookie Reply Message 数据包中的 cookie 字段中包含了 Handshake Initiation 数据包的 mac1 字段,所以 Cookie Reply MessageHandshake Initiation 数据包是一一对应的,防止了这种攻击方式
  • 放大攻击:攻击者从网络链路中获取到攻击目标发送给 wireguard serverHandshake Initiation 数据包。不断地给多个 wireguard server 重放这个数据包,wireguard server 会根据 wireguard 协议生成 Handshake Response 或者 Cookie Reply Message 并发送给攻击目标,从而消耗掉攻击目标的网络流量。
    • 由于 Cookie Reply Message 数据包比 Handshake Initiation 数据包小很多,从根本上防止了这种攻击方式

抗攻击特性总结

攻击类型 防御机制
重放攻击 TAI64N 时间戳 + 滑动窗口计数器
中间人攻击 静态密钥绑定 IP + Cookie 加密传输
DoS 攻击 无状态处理 + 双重 MAC 验证 + 速率限制
密钥泄露 完美前向保密(PFS) + 短期会话密钥
流量分析 固定长度填充 + 零拷贝加密

定时器与无状态用户体验

WireGuard 的用户体验设计追求无状态感知,管理员仅需配置接口私钥与对等方公钥,无需手动管理会话状态。其背后的核心是简洁可靠的定时器状态机,通过精心设计的超时机制与自动重传逻辑,确保通信的健壮性。本章详述各定时器的功能与交互。

预备知识

以下常量定义定时器系统的核心参数:

符号 描述
Rekey-After-Messages 260 条消息 发送指定数量消息后触发密钥轮换
Reject-After-Messages 264−213−1 条 达到此阈值后拒绝继续使用当前会话
Rekey-After-Time 120 秒 会话最大存活时间,超时后主动发起新握手
Reject-After-Time 180 秒 会话绝对有效期,超时后强制废弃
Rekey-Attempt-Time 90 秒 握手重试总时长(含退避)
Rekey-Timeout 5 秒 握手消息重传间隔
Keepalive-Timeout 10 秒 无数据发送时触发保活消息的等待时间

传输消息限制

  1. 密钥轮换触发条件
    • 消息数限制:发送 Rekey-After-Messages 条消息后,主动发起新握手。
    • 时间限制:当前会话存活超过 Rekey-After-Time 秒时,发起方主动轮换密钥。
  2. 会话终止条件
    • 当会话存活超过 Reject-After-Time 接收消息数超过 Reject-After-Messages 时,废弃当前会话。
  3. 被动触发机制
    • 若接收数据后会话年龄接近 Reject-After-Time,且无等待的发送数据,发起方主动轮换密钥,避免通信中断。

密钥轮换

  1. 多会话共存
    • 系统维护当前会话前一会话待确认会话,支持平滑过渡。
    • 新会话建立后,旧会话移至“前一”槽位,确保正在传输的包仍可解密。
  2. 会话清理
    • 若超过 $3×Reject-After-Time3$ 秒未建立新会话,强制清除所有会话状态与临时密钥

握手初始化重传

  1. 首次发送触发
    • 用户首次通过接口发送数据时,若无活跃会话,自动发送握手请求消息。
  2. 超时重传逻辑
    • 发送握手请求后,若 Rekey-Timeout 秒内未收到响应,生成新临时密钥并重传。
    • 持续重试最长 Rekey-Attempt-Time 秒,若仍无响应,放弃并返回错误。
  3. 退避改进计划
    • 当前采用固定重传间隔(5 秒),未来将支持指数退避以优化网络拥塞场景

被动保活

  1. 保活消息生成
    • 若接收有效数据包后,Keepalive-Timeout 秒内无数据需发送,自动生成零长度保活包
    • 保活包通过当前会话加密,含 16 字节认证标签(Poly1305),无实际负载。
  2. 连接健康检测
    • 若超过 $Keepalive-Timeout+Rekey-Timeout$ 秒未收到数据,判定会话失效,触发握手重试。
  1. Cookie 的存储与使用
    • 收到 Cookie 回复消息后,存储解密后的 Cookie 及其接收时间,不立即重传握手请求
    • 等待 Rekey-Timeout 定时器触发时,携带有效 Cookie 重传握手请求。
  2. 抗洪泛攻击
    • 延迟重传机制避免因大量 Cookie 回复导致的带宽滥用,缓解对等方负载压力

设计优势总结

  1. 无状态管理:用户仅配置静态密钥,会话建立、维护与终结全自动化。
  2. 抗网络波动:通过定时重试与保活机制,适应高延迟、不稳定网络环境。
  3. 资源高效:无动态内存分配,握手处理与加密操作在软中断中完成,减少内核开销。
  4. 对称角色:任意对等方可主动发起握手,避免传统 VPN 的主从模式复杂性。

示例场景

  1. 移动网络切换: 对等方 A 从 Wi-Fi 切换到蜂窝网络,IP 从 192.168.1.100 变为 10.0.0.5
    • WireGuard 通过接收数据包更新对等方 B 的端点信息,无需人工干预。
    • 若切换期间会话超时(Reject-After-Time),自动发起新握手恢复通信。
  2. NAT 穿透: 对等方位于对称 NAT 后,通过周期性保活包维持 NAT 映射表项,防止超时断开

Linux 内核实现

WireGuard 的 Linux 内核实现遵循以下核心目标:

  1. 代码精简:总代码量不足 4000 行(不含加密库),便于审计与维护。
  2. 极致性能:与 IPsec 竞争,支持高吞吐场景。
  3. 零动态内存分配:接收路径无内存分配,避免资源耗尽攻击。
  4. 原生集成:兼容标准网络工具(如 ipnetlink)。
  5. 模块化支持:可作为外部内核模块编译,无需修改内核主线代码。

队列系统

  1. 超级数据包处理
    • 启用 GSO(Generic Segmentation Offload,通用分段卸载)分散/聚集 I/O,允许内核将大包聚合为“超级数据包”提交给 WireGuard。
    • 加密后分片为 MTU 大小,批量发送,减少路由查询和缓存未命中,提升吞吐量 35%。
  2. 零拷贝优化
    • 数据包加解密和协议头封装均 原地操作,避免内存复制。
    • 支持 sendfile(2) 系统调用,直接处理用户态文件数据。
  3. 延迟队列
    • 握手未完成时,待发送数据包暂存队列,握手成功后批量处理。

软中断与并行化

  1. 并行加密框架
    • 使用 padata 系统分配并行工作线程,充分利用多核 CPU。
    • 并行阶段:多线程并行加密数据包。
    • 串行阶段:按序发送加密后的数据包,避免乱序。
  2. 低优先级任务
    • 握手消息处理与 Cookie 生成在后台低优先级线程执行,防止 CPU 被占满。
  3. 小包优化
    • 若数据包小于 256 字节或单核环境,直接在软中断(softirq)中处理,减少调度开销。

基于 RTNL 的虚拟接口与容器化

  1. 接口管理
    • 通过 RTNL(Routing Netlink) 注册虚拟接口,支持 ip-link(8)ip-set(8) 工具配置。
    • 使用 Generic Netlink 协议与用户态工具(如 wg(8))通信,未来计划整合至 ip(8)
  2. 容器化支持
    • 利用 网络命名空间 隔离 WireGuard 接口与物理接口。
    • 示例:容器内仅保留 wg0 接口,加密流量通过宿主机物理网卡发送,实现端到端安全。

数据结构与基本操作

  1. 路由表实现
    • 选用 基数树(Radix Trie) 实现加密密钥路由表,支持 RCU (Read-Copy Update)无锁查找,优化查询性能。
  2. 哈希表
    • 使用 SipHash2-4 抗碰撞哈希表存储对等方公钥,防止 DoS 攻击。
  3. 加密原语
    • 直接调用优化后的 ChaCha20-Poly1305 实现(支持 Intel AVX2/AVX512、ARM NEON),绕过内核 Crypto API 的开销。
    • 临时密钥与中间值使用 memzero_explicit 清零,防止内存泄漏。
  4. 速率限制
    • 集成 Netfilter 的 hashlimit 模块,基于 Cookie 对握手消息进行令牌桶限速。

FIB 注意事项

  1. 路由环路预防
    • 提案修改 FIB 路由查询逻辑,支持 排除指定接口(如 wg0)的路由决策。示例:
1
2
3
# 默认路由指向 wg0,但查询物理接口路由时排除 wg0  
$ ip route add 0.0.0.0/1 dev wg0  
$ ip route add 128.0.0.0/1 dev wg0  
  1. 命名空间隔离方案
  • 创建独立网络命名空间,将 WireGuard 接口与物理接口分离,彻底避免环路。

潜在的用户态实现

为了使 wireguard 协议获得更加广泛的使用,作者使用了一些安全的编程语言来实现 wireguard 协议,这些 wireguard 实现是基于 tun 网卡的、跨平台的

  1. 跨平台适配
    • 计划使用 Rust、Go 或 Haskell 开发用户态 TUN 实现,支持非 Linux 系统。
  2. 特性取舍
    • 用户态实现性能低于内核版本,但便于移植和快速迭代。

目前在 wireguard 官方仓库 中可以找到如下实现:

  • (内核态实现)Linux 内核实现
  • (内核态实现)OpenBSD 内核实现
  • (内核态实现)FreeBSD 内核实现
  • (内核态实现)Windows NT 内核实现
  • (跨平台的用户态实现)rust 实现
  • (跨平台的用户态实现)golang 实现
  • (跨平台的用户态实现)Haskell 实现(不再维护)
  • (客户端实现)Android 客户端实现
  • (客户端实现)macOS 和 iOS 客户端实现
  • (客户端实现)Windows 客户端实现

性能

WireGuard 的性能经过严格测试,结果显示其在吞吐量与延迟上均显著优于 IPsec 和 OpenVPN。以下是基于 Intel Core i7 处理器的千兆以太网环境下的测试结果(数据取 30 分钟平均值):


测试环境

  • 设备 A:Intel Core i7-3820QM,网卡 Intel 82579LM(千兆)
  • 设备 B:Intel Core i7-5200U,网卡 Intel I218LM(千兆)
  • 测试工具iperf3

性能对比

指标 WireGuard IPsec (AES-GCM + AES-NI) IPsec (ChaCha20Poly1305 + AVX2) OpenVPN
吞吐量 1.98 Gbps 1.34 Gbps 1.12 Gbps 0.38 Gbps
平均延迟 0.01 ms 0.23 ms 0.45 ms 1.87 ms

关键发现

  1. 吞吐量优势
    • WireGuard 跑满千兆链路(1.98 Gbps),而 IPsec 的两种模式分别为 1.34 Gbps(AES-GCM)和 1.12 Gbps(ChaCha20Poly1305)。
    • OpenVPN 因用户态-内核态数据拷贝开销,吞吐量仅为 0.38 Gbps。
  2. 延迟优势
    • WireGuard 延迟低至 0.01 毫秒,IPsec 的 AES-GCM 模式为 0.23 毫秒,ChaCha20Poly1305 模式为 0.45 毫秒,而 OpenVPN 高达 1.87 毫秒。
  3. CPU 利用率
    • OpenVPN 与 IPsec 测试中 CPU 满载(100%),而 WireGuard 未完全占用 CPU,表明其效率更高,瓶颈在网卡而非计算

技术分析

  1. 加密算法效率
    • AES-GCM:依赖硬件加速(AES-NI),在支持指令集的设备上表现优异,但 WireGuard 仍以更简协议栈胜出。
    • ChaCha20Poly1305:纯软件实现,无侧信道风险,在无 AES-NI 的嵌入式平台(如 ARM、RISC-V)中更具优势。
    • 未来趋势:随着 AVX512 等宽向量指令普及,ChaCha20Poly1305 的吞吐量有望进一步提升。
  2. 架构优势
    • 内核态实现:WireGuard 避免用户态-内核态切换,减少数据拷贝与上下文切换开销。
    • 零拷贝与批处理:支持 GSO 和分散/聚集 I/O,加密操作批量处理,优化缓存利用率。

适用场景

  1. 数据中心
    • WireGuard 的高吞吐与低延迟适合跨机房加密通信,替代传统 IPsec 网关。
  2. 移动设备
    • ChaCha20Poly1305 在手机等无 AES-NI 的设备中性能更优,延长电池续航。
  3. 物联网
    • 低资源消耗特性适合嵌入式设备,保障边缘计算安全。

总结

WireGuard 通过精简协议栈、内核态优化与现代加密算法,实现了接近线速的加密性能。其设计不仅满足当前网络需求,更为未来硬件演进预留扩展空间,成为高性能安全通信的理想选择。

结论

WireGuard 以不足 4000 行代码的体量,证明了一种安全网络隧道协议的可行性:它实现简洁、性能卓越、采用现代加密技术,且易于管理。其简洁性使得它能够轻松通过独立验证,并广泛移植到多样化平台。所使用的加密构造与基础原语确保了跨设备的高效性(从数据中心服务器到智能手机),同时提供了面向未来的可靠安全特性。其易部署性也将消除当前 IPsec 部署中常见的诸多隐患。正如 Ferguson 和 Schneier 在其提出时所言:“IPsec 令我们大失所望。考虑到参与者的专业水准与投入的时间,我们本期待更好的结果。……我们对 IPsec 的主要批评在于其复杂性。”

WireGuard 则反其道而行,聚焦于简洁性与可用性,同时提供可扩展的高安全性系统。通过静默处理未认证数据包、避免动态内存分配及最小化资源占用,它可部署于网络边缘,作为可靠的安全接入点,既不会向攻击者暴露自身存在,也不易成为有效攻击目标。加密密钥路由表范式易于掌握,有助于推动安全的网络设计。该协议基于密码学严谨的保守原则,使用经过充分验证的现代加密原语。WireGuard 从实用角度出发,致力于解决现实世界中的安全网络通信问题。

番外篇

allowed ip 的实现

第三章中介绍了 allowed ip 的用法,但是说的很模糊,至少没有给出如下几个问题的答案:

  1. 不同的 peer 可以有相同的 allowed ip 吗?即 peer apeer ballowed ip 都为 1.1.1.1/32,或者都为 1.1.1.0/24
    • 不可以,后面添加的 peer 会刷掉前面添加的 peer
  2. 如果一个 peerallowed ip 包含了另外一个 peerallowed ip,那么会落到哪个 peer 上呢?即 peer aallowed ip1.1.1.0/24peer ballowed ip1.1.1.1/32,那目的 IP1.1.1.1/32 的包会发送给 peer a 还是 peer b 呢?
    • 会落到 peer b

为了解释上面的问题,看一下 wireguard-go 中关于 allowed ip 的具体实现:wireguard-go 中使用前缀树维护 allowed ip,根结点为 0.0.0.0/0,第1层的掩码长度为1,第2层的掩码长度为2,… … ,第32层的掩码长度为 32

节点的数据结构如下,其中最关键的字段为 childcidrchild 为左右子树的指针,cidr 为掩码长度:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type trieEntry struct {
	peer        *Peer
	child       [2]*trieEntry // 两个子节点
	parent      parentIndirection
	cidr        uint8 // 掩码长度
	bitAtByte   uint8
	bitAtShift  uint8
	bits        []byte
	perPeerElem *list.Element
}

根据 ip 查找 peer 的逻辑如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (node *trieEntry) lookup(ip []byte) *Peer {
	var found *Peer
	size := uint8(len(ip))
	for node != nil && commonBits(node.bits, ip) >= node.cidr { // 走不下去了
		if node.peer != nil {
			found = node.peer
		}
		if node.bitAtByte == size {
			break
		}
		bit := node.choose(ip) // choose 根据 掩码长度 决定向左子树还是右子树 搜索
		node = node.child[bit] // 往下走一层
	}
	return found
}

wg-quick、wg、wireguard-go 三者关系

wg-quick 是平台相关的,用于完成前置工作(如配置路由、MTU、防火墙等)、读取配置文件、调用 wg 来启动 wireguard

wg 是跨平台的,本质上是一个 shell,用于和 wireguard-go 交互;wireguard-go 是真正的协议实现。

其中 wg-quick 和 wg 的实现都在仓库 wireguard-tools - Required tools for WireGuard, such as wg(8) and wg-quick(8) 中;协议实现是在不同的仓库中,汇总在 Repositories - WireGuard

实验

使用 golang 实现 在 Mac 上做一个实验,首先进行编译:

1
2
3
git clone https://git.zx2c4.com/wireguard-go
cd wireguard-go
make

然后启动:

1
wireguard-go wg0

如果使用 wg-quick up wg0 启动,则需要删除 wireguard 内核模块,否则 wg-quick 会优先加载内核 wireguardwg-quick代码实现

1
2
3
4
5
6
7
8
9
add_if() {
	local ret
	if ! cmd ip link add "$INTERFACE" type wireguard; then
		ret=$?
		[[ -e /sys/module/wireguard ]] || ! command -v "${WG_QUICK_USERSPACE_IMPLEMENTATION:-wireguard-go}" >/dev/null && exit $ret
		echo "[!] Missing WireGuard kernel module. Falling back to slow userspace implementation." >&2
		cmd "${WG_QUICK_USERSPACE_IMPLEMENTATION:-wireguard-go}" "$INTERFACE"
	fi
}

使用如下步骤删除 wireguard 内核模块:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 查看是否存在 wireguard 内核模块
sudo lsmod | grep wireguard
# 确保没有进程在使用 wireguard 内核模块
sudo lsof | grep wireguard
# 删除内核模块
sudo rmmod wireguard
# 禁止加载 wireguard 内核模块
sudo vim /etc/modprobe.d/blacklist-wireguard.conf
# 添加如下行在文件中
blacklist wireguard
# 更新 initramfs
sudo update-initramfs -u

# 如果使用的不是wireguard-go,而是其他的用户态实现,如boringtun-cli,则需要指定环境变量
WG_QUICK_USERSPACE_IMPLEMENTATION=boringtun-cli

Cross-platform Userspace Implementation 需要遵守的规则

随着各种用户态实现的涌现,为了避免产生分叉,wireguard 官网制定了所有的用户态实现必须要遵守如下的几个要求,包括:

  1. 支持且只能支持一个命令行指令作为入口,执行这个指令之后,这个用户态实现需要创建好对应的 TUN 设备:
1
# userspace-wg [-f/--foreground] INTERFACE-NAME
  1. 需要创建一个 UNIX domain socket 并监听这个 socket,需要支持两种指令:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
请求值:
get=1
{empty line}

回复:
key1=value1
key2=value2
key3=value3
key4=value4
key5=value5
...
errno=0
{empty line}

第二个:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
请求值:
set=1
key1=value1
key2=value2
key3=value3
key4=value4
key5=value5
...
{empty line}

回复:
errno=0
{empty line}

其中 key 和 value 也是被严格规定的,参考 Cross-platform Interface - WireGuard

  1. 如果收到了 SIGINT/SIGTERM 信号,需要优雅地退出,包括删除 TUN 设备,删除 UNIX domain socket 文件等

参考

最后更新于 Mar 27, 2025 17:40 UTC
Built with Hugo
Theme Stack designed by Jimmy