前言

本文基于 WireGuard Golang 实现,平台是 Linux,Git 版本是:12269c2

内容

关于收发包

通过创建 TUN 设备,跟系统的网络栈交互。

数据抽象的介绍

关于限速器

WireGuard 在最终进行数据的加密、解密之前,需要通过加密协议 Noise Protocol 基于加密算法协商出对称加密密钥(临时的),这个协商的过程(握手)会涉及到加/解密,哈希运算。为了避免针对响应方 CPU 负载的 DoS 攻击,响应方会根据实时负载情况对握手的请求进行限速,根据请求方的来源 IP 实例化对应的令牌桶(每个来源 IP 对应一个令牌桶),基于令牌桶对握手包进行频率限制。默认的限制策略是每秒钟 20 个握手包,每个时刻(即每纳秒)最多允许 5 个握手包的突发流量。

关于索引表

每个数据包的包头第 4 到第 8 字节会存储一个 32 bits 的无符号整数作为索引,通过索引和索引表(map[uint32]IndexTableEntry),可以查到对应的 Peer、Handshake、Keypair。

索引值本身是个随机值,在:

func (table *IndexTable) NewIndexForHandshake(peer *Peer, handshake *Handshake) (uint32, error)

函数中被创建,使用该索引值时使用了两阶段锁(索引表首先基于读锁检查索引是否已被占用,如果该索引未被使用,再基于写锁创建对应的索引条目)。

索引表的作用是?

索引表会在两种场合被使用。

第一种是握手时,WireGuard 初始方创建握手包和消费响应包,完成 Triple-way DH 流程。初始方创建、发送握手包,然后等待、消费响应包,中间有个发送并等待响应的中间状态,这个中间状态的值被存储在 Handshake 实例中,Handshake 作为索引表的值(值是一个结构体,其中一个字段),通过索引和该握手包绑定在一起。当开始消费响应包时,通过响应包头部的接收方 ID(响应方将初始握手包中的发送方 ID 作为接收方 ID 返回),查询索引表从而获得创建握手包时的 Handshake 实例,从而完成整个 Triple-way DH 流程。

第二种是接收到数据包时。响应方在消费握手包并发送响应后,会在本地创建 Peer、Keypair,并更新索引表。接收到的数据包头部的索引值,允许 WireGuard 通过索引表找到握手时创建的 Keypair 和 Peer。Keypair 用于确认该数据包是否有对应的密钥对,加密密钥是否已经过期。Peer 用于定位数据包的队列。数据包基于 Keypair 进行认证和解密,解密后的数据包将添加到 Peer 的队列内等待消费。

关于路由表

在代码中叫 AllowedIPs,结构体类型。相对于系统路由表,这是 WireGuard 内部的路由表实现,基于前缀树。

何时初始化的?通过 IPC 配置 WireGuard 时,比如通过命令行工具 wg 执行:wg set wg0 allowed-ips '10.0.0.2/32' 时触发。

有何作用?

映射地址(地址段)和 Peer 的关系。

分为两种场景。

第一种,初始方发送数据时。此时一个数据包被系统路由表发给 WireGuard 的 TUN 设备,WireGuard 读取 TUN 设备对应的文件描述符中的 IP 包,根据 IP 包的目的地址查询内部路由表,从而确定目标的 Peer 实例,基于 Peer 实例中的信息,加密并发送给 Peer 对应的服务器。这里起到内部路由的作用。

第二种,响应方接收数据时。解密好的 IP 包拿源地址查询内部路由表(要注意,解密前是一个 IP 包,解密后实际上剥离了之前的头部。基于包数据部分解密出了另外一个 IP 包,即 IP in IP),如果 Peer 实例跟未解密时基于接收方 ID 查询索引表获得的 Peer 一致,说明这个解密后得到的 IP 包的确应该发给这个 Peer。这里的作用是基于来源 IP (WireGuard 虚拟 IP)的路径认证。

关于设备

在代码中叫 Device,结构体类型。

在 Userspace 实现中,启动 wireguard-go 二进制时创建。如果是内核实现,则是执行 ip link add dev wg0 type wireguard 时创建。

在 WireGuard Golang 的实现中,Device 是本地 WireGuard 设备的抽象。由许多自定义结构体组合而成,比如:TUN 设备,网络 socket 设备,内部路由表,内存池,加密队列,解密队列,握手队列。

可以认为 WireGuard Device 结构体,是私钥的抽象,每个私钥对应独立的设备。

现实情况:

  • Windows 平台:可以同时运行多个不同名字的 WireGuard 设备。
  • MacOS 平台:可以同时运行多个不同名字的 WireGuard 设备。
  • Linux 平台:可以同时运行多个不同名字的 WireGuard 设备。
  • Android 平台:只能同时运行一个 WireGuard 设备。
  • iOS 平台:只能同时运行一个 WireGuard 设备。

对于后两个平台,如果你需要在设备上实现同时访问多个 WireGuard 网络(无需手动切换设备),你需要基于节点,复用当前 WireGuard 设备,这块见下面节点的介绍。

关于节点

在代码中叫 Peer,结构体类型。WireGuard 没有客户端和服务端的区分,当服务器主动发起握手包我们叫它初始方,当服务器被动响应握手包,我们叫它响应方。我们用 Peer (节点)来取代客户端和服务端的概念。

何时初始化的?通过 IPC 配置 WireGuard 时,比如通过命令行工具 wg 执行:wg set wg0 public_key tUpr9... 时触发。

在 WireGuard Golang 的实现中,节点是远端设备在本地的抽象,可以认为每个对端设备的静态公钥,对应一个节点,每个设备可以连接多个节点。由许多自定义结构体的组合而成,比如:密钥对集合,握手包,端点,收包队列,发包队列,暂存队列,定时器等。

对于不支持同时运行多个 WireGuard 设备的平台(Android、iOS),我们可以复用本地设备,方法是:

  1. 在本地创建一个设备(创建一对公私钥)。
  2. 将本地设备的公钥添加到 A 网络内。
  3. 将本地设备的公钥添加到 B 网络内。
  4. 在本地设备添加 A 网络对端的公钥信息,并指定对端所在办公网的局域网网段到路由表内(allowedIPs)。
  5. 在本地设备添加 B 网络对端的公钥信息,并指定对端所在办公网的局域网网段到路由表内(allowedIPs)。

这样,我们的设备就可以同时访问办公网 A 和 B 的局域网设备了。

关于节点表

在代码中叫做 peers,是 Device 结构体中内嵌的结构体,用于记录对端设备对应的 Peer。实际上是个 map,以公钥为键,Peer 实例指针为值。

关于设备队列

有如下队列:

  1. 加密队列,对应待加密的数据包。
  2. 解密队列,待解密的数据包。
  3. 握手队列,待消费的握手包。

设备的队列会有多个 gorouting 并发的尝试消费这些队列。

关于节点队列

  1. 节点暂存队列。在未完成握手,暂时无法加密时产生的待加密的数据包。
  2. 待读取队列。存放已解密待消费的数据包的队列,会被发给 TUN 设备,作为网络包接收后,Linux 网络栈发给本地对应的服务或者路由给其他设备。
  3. 待发送队列。存放已加密待发送的数据包的队列,会被发给服务器。

暂存队列中的数据包是未加密状态的待发送数据包,会在 func (peer *Peer) SendStagedPackets() 中消费。待读取队列和待发送队列是串行消费的。

关于端点

在代码中叫做 endpoint,记录真实源地址和目标地址,比如公网服务器的地址是 1.1.1.1,客户端通过运营商的 NAT 设备(2.2.2.2)发送请求到公网服务器。那么 endpoint 结构体中记录的信息就是目标地址是:1.1.1.1 源地址是 2.2.2.2。

目标地址一般是客户端通过 wg(命令行工具)手动配置的,原理是 wg 命令通过 IPC(进程间通信)向 WireGuard 的 IPC socket 发送特定格式的消息,WireGuard 会消费这些 IPC 消息,从而配置 WireGuard 程序。这块是 Userspace 的 WireGuard 实现所特有的,对于内核级的实现则是直接基于 Netlink 进行交互和配置。目标地址也会在收到数据包后自动更新为数据包中最新的来源 IP。

关于握手

在代码中叫 Handshake,结构体类型。

何时初始化?在 func (device *Device) NewPeer(pk NoisePublicKey) (*Peer, error) 方法中初始化的 Handshake 实例。

作用是?每个节点会有自己的 Handshake 实例,存储该节点握手流程中密钥衍生操作(非对称加密到对称加密)所需的中间状态的值。

关于密钥对

在代码中叫 Keypair,结构体类型。这个结构体包含了对称加密所需的值,比如对 cipher.AEAD 接口类型的实例(有两个,对发送和请求分别进行加密),所以叫做密钥对。

密钥对在 BeginSymmetricSession 中创建,基于握手包的值创建。

关于密钥对集合

在代码中叫 Keypairs,结构体类型。WireGuard 每隔 3 分钟就会重新握手,更换新的对称加密密钥(即创建新的 Keypair 替换旧的 Keypair),这里面会有三种状态的 Keypair:之前使用的 Keypair、当下在用的 Keypair、即将启用的 Keypair,密钥对集合就是用来记录这三种状态的 Keypair 实例的集合。

关于 Nonce 值

Nonce 值是流加密和认证中的一个关键参数,在 chacha20poly1305 这套 AEAD 中,Nonce 的大小是 12 字节。chacha20poly1305 需要 Nonce 是一个唯一的只使用一次的值。

数据包解密基于 ChaCha20Poly1305,第 8 至第 16 字节(共计 8 个字节) 是一个计数器,用于实现随机的 Nonce 值(前四个字节是 0,后 8 个字节是计数器的值)。

数据处理的逻辑

数据接收的协程

接收数据的操作通过独立的 goroutine 来实现。相关的函数是:

func (device *Device) RoutineReceiveIncoming(maxBatchSize int, recv conn.ReceiveFunc){ ... }

什么场景会启动接收数据的 goroutine ?

  1. 用户配置 WireGuard,通过 wg 命令
  2. Linux 启动 TUN 设备,通过 ip 命令

第 1 点这里对应命令:wg set wg0 listen-port 51820,WireGuard 程序内部通过 UAPI 进行通信(实际上是一个 Unix domain socket),路径是:/var/run/wireguard/wg0.sock,当执行前面的命令时,wg 会向 /var/run/wireguard/wg0.sock 发送消息,WireGuard 程序监听消息,如果是配置监听端口(即命令中有 listen-port 参数)的命令,会基于最新的端口启动监听的 goroutine。

第 2 条这里对应的命令:ip link set wg0 up,这条命令通过 NetLink 消息启动 TUN 设备,内核会将消息发给 WireGuard 进程,进程捕获 NetLink 消息,确认是 TUN 设备的启动事件,启动操作中会启动新的接收数据的 goroutine。

如何接收数据?

接收数据的系统调用封装在:func (s *StdNetBind) Open(uport uint16) ([]ReceiveFunc, uint16, error) 内。Linux 支持一次系统调用读取多个数据包,因此实际上是对:recvmmsg 这个 Linux 系统调用的封装,以实现批量读取数据包,并通过数据包控制信息(unix.PKTINFO)解析数据包来源地址,实时更新 Peer 的端点(endpoint)地址。

在接收数据包前,会在一个循环内初始化接收(批量)数据所需的内存空间,这些内存空间是固定大小的字节数组(每个数据包存储在对应的字节数组中)。由于数据包接收和处理(每个数据包一段内存,处理完毕就回收)会产生大量的内存分配和垃圾回收操作,导致性能瓶颈,因此项目中通过 sync.Pool 来复用数据包内存空间。

项目中还通过 sync.Cond 来实现内存控制(默认是关闭的状态,即不限制内存增长),原理是通过封装 sync.Pool.Get()sync.Pool.Put() 操作,在 Get 操作的封装中统计分配操作的次数(原子操作,每分配一个内存空间,计数器加 1),当计数器的值超过最大值时,通过 sync.Cond.Wait() 阻塞进程,暂停后续的 sync.Pool.Get() 操作,停止内存分配。在 Put 操作的封装中通过 sync.Pool.Put() 将内存空间归还,并将计数器减 1,通过 sync.Cond.Signal() 触发因 sync.Cond.Wait() 阻塞的进程继续运行。

如何处理收到的网络包?

数据接收是通过一个循环来触发的,循环内通过调用 recvmmsg 的封装,实现对数据包的批量接收。接收到的数据包根据第 1 个字节判断数据包类型。

有 4 个消息类型,对应握手包(多个状态)和数据包。

数据包的处理

因为是批量接收,因此每次系统调用,[]*[MaxMessageSize]byte 类型的 bufsArrs 变量中的字节数组会被填充上一个或多个数据包的数据。这里需要一个循环来遍历每一个数据包(数据包可能是不同的 Peer 的)。

每个数据包的第 4 到 第 8 个字节在代码中被称为 Receiver,是一个 32 bits 的 ID(4 个字节),这个 ID 会通过 索引表 来记录和查询数据包对应的节点(Peer),密钥对(Keypair),握手(Handshake)。根据这些信息,可以判断数据临时密钥是否过期,是否是重发攻击,以及数据包属于哪个节点(Peer)。

WireGuard 为了确保前向保密,每隔一段时间或者一定的数据量后,会基于非对称加密,创建新的临时密钥(对称加密的时候使用)来加密数据包。因为数据是通过轮询的临时密钥加密的,即使数据包被劫持,没有特定时间点的临时密钥,数据包也无法解密。每次创建的临时密钥(对称加密)会被抽象为 session 的概念,每个 session 有一定的生命周期。

在接收到数据包后,会基于数据包头部的 Receiver 查索引表获取密钥对信息,获取到的信息里面包含了一个时间戳,密钥对需是 3 分钟内创建的才会被认为是有效的。

密钥对如果是有效的,会创建一个 QueueInboundElement 结构体实例来记录数据包数据(数据包内容,Keypair,Handshake,endpoint 等)。

type QueueInboundElement struct {
	// 数据包对应的内存地址
	buffer   *[MaxMessageSize]byte
	// 有效数据对应的切片(数据包对应的内存大小是一致的,但实际有效的数据只会占用其中一小部分的内存空间)
	packet   []byte
	counter  uint64
	keypair  *Keypair
	endpoint conn.Endpoint
}

每一个接收函数内会有一个 map elemsByPeer = make(map[*Peer]*QueueInboundElementsContainer, maxBatchSize),记录节点(Peer)和未处理的数据包(多个)的关系,多个数据包的指针被记录在 QueueInboundElementsContainerelems 切片内:

type QueueInboundElementsContainer struct {
	sync.Mutex
	elems []*QueueInboundElement
}

接收函数最后将 QueueInboundElementsContainer 发送给 device.queue.decryption.c / peer.queue.inbound.c 两个 channel。这两个 channel 会在:RoutineDecryptionRoutineSequentialReceiver 中被消费。

在 WireGuard Golang 实现中,解密和消费解密后的数据是在两个独立的协程中进行的,通过将接收到的数据包的指针发给两个 channel,从而在独立的协程中解密并消费数据包中的数据。项目中是如何确保先解密后消费的(正如我说的,解密和消费是两个独立的协程)?

实际上在创建数据包(未解密状态)和消费数据包(已解密状态)这个过程中,通过互斥锁做了一个阻塞同步(确保先解密后消费)。

// 数据包接收
func (device *Device) RoutineReceiveIncoming(maxBatchSize int, recv conn.ReceiveFunc) {

	for {
		count, err = recv(bufs, sizes, endpoints)

		// handle each packet in the batch
		for i, size := range sizes[:count] {
			// ...
			packet := bufsArrs[i][:size]
			msgType := binary.LittleEndian.Uint32(packet[:4])
			switch msgType {
			case MessageTransportType:
				// ...
				peer := value.peer
				elem := device.GetInboundElement()
				elem.packet = packet
				elem.buffer = bufsArrs[i]
				elem.keypair = keypair
				elem.endpoint = endpoints[i]
				elem.counter = 0

				elemsForPeer, ok := elemsByPeer[peer]
				if !ok {
					elemsForPeer = device.GetInboundElementsContainer()
					elemsForPeer.Lock()
					elemsByPeer[peer] = elemsForPeer
				}
				elemsForPeer.elems = append(elemsForPeer.elems, elem)
				// ...
				continue
			case MessageInitiationType:
	        // ...
		}
		for peer, elemsContainer := range elemsByPeer {
			if peer.isRunning.Load() {
				peer.queue.inbound.c <- elemsContainer
				device.queue.decryption.c <- elemsContainer
			}
			// ...
		}
	}
}

可以看到这里接收并创建 elem 后,将 elem 作为 elemsForPeer.elems 切片成员的值,在创建 elemsForPeer 时有个 elemsForPeer.Lock() 的操作。并将 elemsForPeer 发送给两个队列(解密和消费)的 channel。

// 解密的逻辑
func (device *Device) RoutineDecryption(id int) {
	var nonce [chacha20poly1305.NonceSize]byte

	for elemsContainer := range device.queue.decryption.c {
		for _, elem := range elemsContainer.elems {
			//...
			elem.packet, err = elem.keypair.receive.Open(
				content[:0],
				nonce[:],
				content,
				nil,
			)
		}
		elemsContainer.Unlock()
	}
}

在解密完成时有个 elemsContainer.Unlock() 操作。

func (peer *Peer) RoutineSequentialReceiver(maxBatchSize int) {
	// ...
	bufs := make([][]byte, 0, maxBatchSize)
	for elemsContainer := range peer.queue.inbound.c {
		if elemsContainer == nil {
			return
		}
		elemsContainer.Lock()
		for i, elem := range elemsContainer.elems {
			if elem.packet == nil {
				// decryption failed
				continue
			}
			// ...
			switch elem.packet[0] >> 4 {
			case 4:
				// ...
			case 6:
				// ...
			}

			bufs = append(bufs, elem.buffer[:MessageTransportOffsetContent+len(elem.packet)])
		}

		if len(bufs) > 0 {
			_, err := device.tun.device.Write(bufs, MessageTransportOffsetContent)
		}
	}
}

在消费数据时,有个 elemsContainer.Lock() 的操作,这个操作将会阻塞数据的消费直到解密完成,释放锁的时候才会继续。

这样就能确保先解密,后消费了。

数据包解密

数据包解密的逻辑封装在 func (device *Device) RoutineDecryption(id int) 内,RoutineDecryption 协程在程序启动时运行,协程的数量跟 CPU 数量一致,比如 4 核 8 线程的服务器,会有 8 个 RoutineDecryption 协程会被启动,用于并行的解密接收到的数据包。

每个数据包通过索引和索引表检索后,会拿到自己的密钥对(Keypair)结构体实例。Keypair 实例的创建是在 func (peer *Peer) BeginSymmetricSession() error 内。

BeginSymmetricSession 包含了:非对称加密密钥衍生出对称加密密钥,基于 ChaCha20Poly1305 进行加密,密钥轮询等逻辑的封装。是从非对称加密生成对称加密的临时密钥并进行密钥轮询开始的地方。

数据包消费

peer.queue.inbound.c 会在 RoutineSequentialReceiver 协程中被消费。RoutineSequentialReceiver 会在 Peer 启动时启动,Peer 会在 TUN 设备启动或用户执行任何 wg set 命令时被启动。因此,数据包消费的协程会被如下操作启动:

  1. 用户配置 WireGuard 的时候,通过 wg set 命令
  2. Linux 启动 TUN 设备,通过 ip 命令

RoutineSequentialReceiver 是串行接收的。这块的实现依赖数据包的计数器值和过滤器(Filter)结构体,通过 func (f *Filter) ValidateCounter(counter, limit uint64) bool 方法确保数据包的新鲜度,抵抗重放攻击1

如何确保封包新鲜度,避免重放攻击?

ValidateCounter 函数通过滑动窗口和位图来解决,基于 RFC 6479 实现。

这份 RFC 来自华为的两位工程师,思路是通过记录最新的序列号,将序列号按照指定大小进行分块,比如 127 可以拆分为 64 + 63,191 可以拆分为 64 + 64 + 63,255 可以拆分为 64 + 64 + 64 + 63,这里拆分后的块数,比如 127 是 1 + 1 = 2, 191 是 2 + 1 = 3, 255 是 3 + 1 = 4 作为索引(索引从 0 开始,所以实际上前面的例子对应的索引是 1、2、3)。

然后将每个块的值作为位图进行存储。因为每个块的值实际上是 0 ~ 63,所以对应的位图是 64 bits 的无符号整数,通过更新这个整数值的比特位(将对应位置的值置 1),来表示对应序列号的数据包是否已经被接收。也就是说,算法会先通过求 64 的商,确认块的索引,然后再记录余数。余数通过位图进行存储,好处是可以在 O(1) 复杂度下确认该数据包是否已经被接收过。

滑动窗口通过最后一个包的序列号和窗口大小的值来判断数据包是否过旧(最新数据包的序列号减去窗口大小,即为允许接收最旧的数据包的序列号)。

基于滑动窗口和位图(预防重放攻击)实现中,会有如下可能的场景出现:

  • 如果序列号比最后一个接收到的数据包的序列号大。这是正常的数据包接收行为。
    • 序列号对应相同的块索引。直接读取或者更新对应的位图。
      • 如果对应块索引中的位图的比特位是 0,说明是新的数据包,接收它并更新位图对应的比特位为 1。
      • 如果对应块索引中的位图的比特位已经是 1,说明出现了重放攻击。
    • 序列号对应不同的块索引。在读取和更新对应索引的位图前,将新的块索引对应的位图置 0(初始化)。
  • 如果序列号比最后一个接收到的数据包的序列号小。
    • 序列号在窗口大小允许范围内(代码中窗口大小是:( 128 - 1 ) * 64)则进行读取或更新的操作。
      • 如果对应块索引中的位图的比特位是 0,说明是新的数据包,接收它并更新位图对应的比特位为 1。
      • 如果对应块索引中的位图的比特位已经是 1,说明出现了重放攻击。
    • 否则为过期数据包,直接丢弃。

数据包然后会通过 tun.Device 接口中的 Write 方法,写入到对应平台的 TUN 设备实现。这个过程就是虚拟网卡模拟物理网卡接收数据包的过程。

握手包的处理

握手包会被发送到 device.queue.handshake.c channel 中,这是一个带缓冲的 channel,默认值是一个常量 QueueInboundSize (1024),因此可以认为在高负载场景,最多会有 1024 个握手包的指针副本被缓冲在通道内。

这个 channel 会在 goroutine func (device *Device) RoutineHandshake(id int) 中被消费。RoutineHandshake 协程在程序启动时运行,协程的数量跟 CPU 数量一致,比如 4 核 8 线程的服务器,会有 8 个 RoutineHandshake 协程会被启动,用于消费所有合法的握手包信息。

type QueueHandshakeElement struct {
	msgType  uint32
	packet   []byte
	endpoint conn.Endpoint
	buffer   *[MaxMessageSize]byte
}

每个握手包的数据会作为上述结构体类型的实例,在 RoutineHandshake 中消费。握手包有 3 中类型,分别是:

  • MessageInitiationType:开始握手时发出的初始握手包
  • MessageResponseType:收到初始握手包后返回的响应握手包
  • MessageCookieReplyType:如果接收方(即返回响应握手包的一方)因为大量的初始握手包而出现高负载,将返回 cookie 响应包(里面包含一个 cookie)。默认情况下一个握手包会有两个 MAC 码,msg.mac1 必须存在且有效,msg.mac2 码,是发送方基于 cookie 生成的,在高负载的时候,即使 msg.mac1 有效,但是无有效的 msg.mac2,该数据包也可能会被丢弃。

如何判断高负载?

握手包会被暂存在设备的握手队列中,默认队列可以暂存 1024 个握手包。

当一个新的握手包被处理时,会根据队列中待处理的握手包个数(也就是队列长度),来判断是否高负载。如果待处理个数大于等于 128 即为高负载。这块的思路可以用来判断当前服务是否正在被 DoS 攻击。

因为有多个 RoutineHandshake 协程,因此可能会有两种情况:

  1. 当前握手包处理时,出现高负载。此时需要将高负载会持续的时间记录(默认是在当前时间戳上加 1 秒,作为高负载状态的持续时间)下来。然后返回 true 表示当前握手包处理时正处于高负载状态。
  2. 当前握手包处理时,未出现高负载,但其它的协程处理时出现高负载,当前握手包处理的时间点刚好在其他协程的高负载状态的持续时间内,此时也应该返回 true。

消息认证码(MAC)如何生效?

每个数据包必须有 msg.mac1,在出现高负载时,必须有 msg.mac2。

msg.mac1 和 msg.mac2 构成了加密数据包末尾的 32 个字节,假设加密后数据包的总长度是 n 字节,那么 msg.mac1 是 msg[n-32:n-16],msg.mac2 是 msg[n-16:n]。

msg.mac1 是除掉末尾 32 个字节的数据包内容通过 blake2s 求得的 128 位哈希值(即生成的哈希的长度是 128 bits,换算成字节是 16 bytes),并且哈希值的计算基于密钥参数。

func() {
   // 密钥参数是:st.mac1.key[:]
   mac, _ := blake2s.New128(st.mac1.key[:])
   mac.Write(msg[:smac1])
   mac.Sum(mac1[:0])
}()

密钥参数如何产生?

密钥参数是一串固定的字符串 mac1---- 和响应方的公钥通过 blake2s 哈希算法不带密钥参数计算而来的:

func() {
   // 密钥参数是 nil
   hash, _ := blake2s.New256(nil)
   // 内容是 []byte(WGLabelMAC1) + pk[:]
   hash.Write([]byte(WGLabelMAC1))
   hash.Write(pk[:])
   // 哈希结果写到 st.mac1.key[:0] 内
   // 由于我们是想要替换 key 中的值而不是追加到末尾,所以这里将长度为 0 的新切片作为 Sum 函数的参数
   hash.Sum(st.mac1.key[:0])
}()

默认初始握手包的 msg.mac2 是 0,只有在响应方返回了 MessageCookieReplyType 类型的握手包后,初始方才会计算并填充 msg.mac2 的值。

响应方何时发送 MessageCookieReplyType 类型握手包?

出现高负载的时候。

MessageCookieReplyType 类型的握手包中包含什么内容?

代码如下:

type MessageCookieReply struct {
	Type     uint32
	Receiver uint32
	Nonce    [chacha20poly1305.NonceSizeX]byte
	Cookie   [blake2s.Size128 + poly1305.TagSize]byte
}

握手包中的关键信息是 XChaCha20-Poly1305 加密的 cookie 值。

加密函数如下:

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

会涉及下面的参数:

  1. 加密密钥。即:st.mac2.encryptionKey[:]
  2. Nonce。即:reply.Nonce[:]
  3. 明文数据(未加密前的 cookie)。即:cookie[:]
  4. 额外数据。即:msg[smac1:smac2]

加密密钥的内容是什么?

是字符串 cookie-- 和响应方的公钥的哈希,基于 blake2s.New256 计算得到的 32 bytes 的哈希值。

创建的函数如下:

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

	st.mac2.secretSet = time.Time{}
}

如何创建 Nonce?

Nonce 有两类。

  1. 发送数据使用的 Nonce 值。这个值是初始方发送数据包的时候更新的,默认握手成功后,从 0 开始原子自增直到计数器到达允许的最大数据包的数量(此时会触发重新握手,创建新的对称加密所需密钥对)。
  2. 高负载时创建 Cookie 响应时使用的 Nonce 值。在响应方创建 Cookie 响应时创建,是个随机值,并作为响应包内容的一部分发送给初始方。在 CreateReply 中被创建。

明文数据(未加密前的 cookie)中的内容是?

请求初始方的来源 IP(如果是 NAT 后的家庭用户,则是 NAT 公网地址)的哈希值。

在如下函数中创建:

func (st *CookieChecker) CreateReply( msg []byte, recv uint32, src []byte, ) (*MessageCookieReply, error){
	st.RLock()

	// refresh cookie secret

	if time.Since(st.mac2.secretSet) > CookieRefreshTime {
		st.RUnlock()
		st.Lock()
		// 如果 cookie 过旧,生成新的临时密钥参数,从而产生新的 cookie 哈希值
		_, err := rand.Read(st.mac2.secret[:])
		if err != nil {
			st.Unlock()
			return nil, err
		}
		st.mac2.secretSet = time.Now()
		st.Unlock()
		st.RLock()
	}

	var cookie [blake2s.Size128]byte
	func() {
		mac, _ := blake2s.New128(st.mac2.secret[:])
		// src 实际上是初始方的公网(出口) IP
		mac.Write(src)
		mac.Sum(cookie[:0])
	}()
	// ...
}

额外数据的内容是?

实际上是 msg.mac1,来自初始方的初始握手请求。

不会被加密,但会影响密文的输出(实际 cookie 值)。

对于 XChaCha20-Poly1305,密文的长度等于明文数据长度 + 标签长度,XChaCha20 将会把明文数据加密为密文数据(长度不变)。

标签(16 bytes)实际上是经过 XChaCha20 加密后的密文和额外数据的哈希。XChaCha20 基于 Nonce 和 32 bytes 的密钥作为参数,加密一个 32 bytes 的全 0 字节数组。 取结果的前 16 bytes 作为 Poly1305HMAC 密钥。计算密文和额外数据得到 16 bytes 的标签值。

所以 cookie 值实际上就是初始方出口 IP 经过哈希和加密后的密文(且密文末尾带有 16 bytes 的验证标签)。

二者的区别是 Nonce 值大小的不同,前者是 24 bytes 后者是 12 bytes。

首先说说二者的使用场景。前者 XChaCha20-Poly1305 用于 Cookie 响应握手包的加密,后者 ChaCha20-Poly1305 用于数据包的加密。

这样做的目的是避免 Nonce 重复导致的公钥泄露。WireGuard 数据包的加密传输每 2 分钟会更新密钥对,使用新的临时密钥来加密传输数据,因此,传输数据包的 Nonce 是一个计数器,在这个计算器超过 12 bytes 允许的最大值前,密钥已经被更新了,所以 Nonce 值是否重复不会影响加密安全性。

而 Cookie 响应握手包是基于响应方的公钥进行加密的(永远不变),且 Nonce 是随机值,为了尽可能的避免 Nonce 重复,这里的 Nonce 大小就很重要了。相比于 12 bytes 的值,24 bytes 的随机值可以极大的降低重复的可能性。

初始方如何消费 MessageCookieReplyType 响应?

消费 MessageCookieReplyType 的消费入口是

func (device *Device) RoutineHandshake(id int){
	// ...
}

客户端进程会接收数据包,根据数据包类型,调用下面的函数:

func (st *CookieGenerator) ConsumeReply(msg *MessageCookieReply) bool {
	// ...

	// 必须含有有效的 msg.mac1
	if !st.mac2.hasLastMAC1 {
		return false
	}

	// ...
	var cookie [blake2s.Size128]byte

	// cookie 基于响应方的公钥的哈希作为密钥,初始方收到 cookie 后,会在本地构建相同的哈希
	xchapoly, _ := chacha20poly1305.NewX(st.mac2.encryptionKey[:])
	// 如果 err 为 nil 说明解密和校验成功,返回 true
	// 如果 err 不为 nil,说明解密和校验失败,返回 false
	_, err := xchapoly.Open(cookie[:0], msg.Nonce[:], msg.Cookie[:], st.mac2.lastMAC1[:])
	if err != nil {
		return false
	}

	// 记录最新的时间戳
	st.mac2.cookieSet = time.Now()
	// 解密后的内容被保存到初始方内存中
	st.mac2.cookie = cookie
	return true
}

消费 cookie 响应包。初始方本地 Peer 中的 st.mac2.cookieSet 时间戳被更新。

含有 msg.mac2 的握手包何时发出?

收到 MessageCookieReply 类型的握手包后,不会立马触发新的握手请求。因为 msg.mac2 的目的是为了避免服务端高负载(因为响应握手包本身涉及密钥衍生等CPU密集的加密学操作)。 在建立握手前,WireGuard 会以特定间隔(Rekey-Timeout,代码中是 5s)尝试建立握手(限制尝试的次数)。含有有效 msg.mac2 的握手包将在收到有效的 cookie 值后的下一次尝试中创建。

如何基于令牌桶实现握手包限速?

每个 IP 会有自己的令牌桶,随时间累计直到最大值,每次请求都有成本,如果请求太频繁而消耗了所有令牌,则进一步的请求会被拒绝,直到令牌桶中再次积累足够的令牌。

请求成本的单位是纳秒(时间),假设 1 秒内允许 20 个包。

const (
    packetsPerSecond   = 20
    packetCost         = 1000000000 / packetsPerSecond
)

// packetCost = 1000000000 / 20 = 50000000

这表示每个请求将消耗 50000000 纳秒的“时间”令牌,在这个例子中为每 50 毫秒一个请求。

每个 IP 地址的令牌桶的令牌是通过计算时间差来累加的。在 Allow 方法中,这一点通过计算距离上一次访问的时间来实现:

now := rate.timeNow()
entry.tokens += now.Sub(entry.lastTime).Nanoseconds()
entry.lastTime = now
  • 获取当前时间 now
  • now.Sub(entry.lastTime).Nanoseconds() 计算自上次访问以来经过的纳秒数。
  • 将这些纳秒数加到 entry.tokens 中。

为了防止令牌数无限增加,需要有一个最大令牌限制,通过 maxTokens 定义的:

if entry.tokens > maxTokens {
    entry.tokens = maxTokens
}

如果通过时间差累加后的令牌数超过了这个最大值,就将令牌数设置为最大令牌数 maxTokens,确保了令牌桶不会因为长时间未使用而累积过多令牌。

每次请求都会尝试从令牌桶中消耗一定数量的令牌(由 packetCost 定义):

if entry.tokens > packetCost {
    entry.tokens -= packetCost
    return true
}

如果当前令牌桶中的令牌数大于每次请求需要消耗的令牌数 packetCost,则从中扣除相应的令牌,并允许这次请求通过。如果令牌不足,请求将被拒绝。

令牌桶的创建和清理以秒为单位,如果一个客户端的多个请求之间间隔时间操作 1 秒,这个客户端的令牌桶会被认为过期而被清理。会为每个请求创建自己独立的令牌桶。 默认令牌桶创建时会分配的 token 数量如下:

packetCost = 1000000000 / packetsPerSecond
maxTokens  = packetCost * packetsBurstable

假设 packetsPerSecond 为 20,这意味着每个请求的成本为:

packetCost = 1000000000 / 20 = 50000000 // 纳秒

如果 packetsBurstable 设为 5,则:

maxTokens = 50000000 * 5 = 250000000 // 纳秒

这表示,当令牌桶完全充满时,它包含的令牌数量能够支持连续处理 5 个请求,每个请求的时间成本为 50000000 纳秒。

这样的设置使得令牌桶在初始状态下即具有一定的请求处理能力,可以应对突发流量。同时,通过适当调整 packetsPerSecondpacketsBurstable 参数,可以根据具体的应用场景和性能要求灵活配置令牌桶的行为。

每个客户端 IP 对应的令牌桶会被存储在一个 map 内:

type Ratelimiter struct {
	mu      sync.RWMutex
	timeNow func() time.Time

	stopReset chan struct{} // send to reset, close to stop
	// table 就是用来记录所有的令牌桶的
	table     map[netip.Addr]*RatelimiterEntry
}

为了及时的清理无用的令牌桶(同一个客户端的两次请求相隔的时间大于 1 秒),会在首次往这个表内添加令牌桶时,启动定时清理器。

下面的代码主要是初始化清理器(停止状态)。

func (rate *Ratelimiter) Init() {

	// ...

	// zh
	go func() {
		ticker := time.NewTicker(time.Second)
		ticker.Stop()
		for {
			select {
			// 默认阻塞
			case _, ok := <-stopReset:
				ticker.Stop()
				if !ok {
					return
				}
				// 启动新的定时器
				ticker = time.NewTicker(time.Second)
			// 默认阻塞,因为在执行 for 之前,ticker 已经被 stop
			case <-ticker.C:
				if rate.cleanup() {
					// 成功执行清理任务后,定时器关闭
					ticker.Stop()
				}
			}
		}
	}()
}

下面的代码发送一个信号给 stopReset 通道,启动清理器协程。

// 如果 map 的长度从 0 变为 1
if len(rate.table) == 1 {
	rate.stopReset <- struct{}{}
}

握手包是否会被限速?

如果响应方出现高负载,则会对握手包进行限速。

func (device *Device) RoutineHandshake(id int) 中包含限速相关函数的调用。

WireGuard 中的定时器是如何工作?

WireGuard 中使用了很多定时器。主要的逻辑在下面的函数内:

func (peer *Peer) timersInit() {
	peer.timers.retransmitHandshake = peer.NewTimer(expiredRetransmitHandshake)
	peer.timers.sendKeepalive = peer.NewTimer(expiredSendKeepalive)
	peer.timers.newHandshake = peer.NewTimer(expiredNewHandshake)
	peer.timers.zeroKeyMaterial = peer.NewTimer(expiredZeroKeyMaterial)
	peer.timers.persistentKeepalive = peer.NewTimer(expiredPersistentKeepalive)
}

这个函数要结合 NewTimer 的实现来看:

func (peer *Peer) NewTimer(expirationFunction func(*Peer)) *Timer {
	timer := &Timer{}
	// 使用 Go timer.AfterFunc API,指定超时后的逻辑
	timer.Timer = time.AfterFunc(time.Hour, func() {
		timer.runningLock.Lock()
		defer timer.runningLock.Unlock()

		timer.modifyingLock.Lock()
		if !timer.isPending {
			timer.modifyingLock.Unlock()
			return
		}
		timer.isPending = false
		timer.modifyingLock.Unlock()

		// 这里执行逻辑
		expirationFunction(peer)
	})
	timer.Stop()
	return timer
}

NewTimer 基于软件工程的工厂模式,每个定时器的到期行为被封装在 expirationFunction 函数值中,当定时器到期时,这个函数被调用。不同的参数(函数值)会创建出具有不同过期逻辑的定时器。

每一个握手包被发送后,会调用定时器 reset 方法激活定时器并配置新的超时时间。代码在如下函数中:

func (peer *Peer) SendHandshakeInitiation(isRetry bool) error {
	// ...
	peer.timersHandshakeInitiated()

	return err
}
func (peer *Peer) timersHandshakeInitiated() {
	if peer.timersActive() {
		peer.timers.retransmitHandshake.Mod(RekeyTimeout + time.Millisecond*time.Duration(fastrandn(RekeyTimeoutJitterMaxMs)))
	}
}
func (timer *Timer) Mod(d time.Duration) {
	timer.modifyingLock.Lock()
	timer.isPending = true
	timer.Reset(d)
	timer.modifyingLock.Unlock()
}

可以看到 Mod 函数封装了 timer.Reset(d)

因为 retransmitHandshake timer 中封装了超时后的逻辑函数 expiredRetransmitHandshake,对应的逻辑如下:

func expiredRetransmitHandshake(peer *Peer) {
		if peer.timers.handshakeAttempts.Load() > MaxTimerHandshakes {
			// ...
		} else {
		peer.timers.handshakeAttempts.Add(1)
		peer.device.log.Verbosef("%s - Handshake did not complete after %d seconds, retrying (try %d)", peer, int(RekeyTimeout.Seconds()), peer.timers.handshakeAttempts.Load()+1)

		/* We clear the endpoint address src address, in case this is the cause of trouble. */
		peer.markEndpointSrcForClearing()

		// 开始下一次握手,并标记为尝试(会计入总尝试次数内,代码中限制总次数最大 18 次)
		peer.SendHandshakeInitiation(true)
	}
}

如果定时器超时,expiredRetransmitHandshake 函数会被调用,这样一个含有 msg.mac2 的全新的初始握手包就被发出了。

如何衍生对称加密的密钥?

初始方在收到有效的 MessageResponseType 类型的响应握手包后,根据之前非对称加密的相关变量值来切换状态。逻辑在 func (peer *Peer) BeginSymmetricSession() error 内。

响应方在收到有效的 MessageInitiationType 类型的初始化握手包后开始,也是调用 func (peer *Peer) BeginSymmetricSession() error

初始方的 chainkey 是如何产生的?响应方的 chainkey 是如何产生的?

chainkey 是基于密钥衍生函数(KDF)创建的,密钥衍生函数实际上是封装 HMAC。

HMAC 类的函数有两种,根据输入数据的参数个数分为:

  1. func HMAC1(sum *[blake2s.Size]byte, key, in0 []byte)
  2. func HMAC2(sum *[blake2s.Size]byte, key, in0, in1 []byte)

HMAC 函数实际上以 blake2s 作为哈希函数,计算数据 in0 in1key 为密钥时的认证码(MAC)。

KDF 类的函数有三种,根据需要衍生出的密钥的个数分为:

  1. func KDF1(t0 *[blake2s.Size]byte, key, input []byte)
  2. func KDF2(t0, t1 *[blake2s.Size]byte, key, input []byte)
  3. func KDF3(t0, t1, t2 *[blake2s.Size]byte, key, input []byte)

密钥衍生的思路是,根据输入的 keyinput 通过 HMAC1 (上面提到的第一类 HMAC 函数)函数计算出一个第一个消息认证码 prkt0 是以 prk 作为密钥,[]byte{0x1} 作为数据再调用一次 HMAC1 的结果,代码如下:

func KDF1(t0 *[blake2s.Size]byte, key, input []byte) {
	HMAC1(t0, key, input)
	HMAC1(t0, t0[:], []byte{0x1})
}

相比于 KDF1,下面是 KDF2 的代码:

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[:])
}

可以看到,t1 实际上是基于 t0[]byte{0x2} 作为数据,基于 prk[:] 作为密钥的消息认证码。 同理,KDF3 中的 t2 是基于 t1[]byte{0x3} 作为数据,基于 prk[:] 作为密钥的消息认证码。

func (peer *Peer) BeginSymmetricSession() error {

	// ...

	if handshake.state == handshakeResponseConsumed {
		KDF2(
			// 初始方基于 chainKey 衍生出发送和接收所需的对称加密密钥
			&sendKey,
			&recvKey,
			handshake.chainKey[:],
			nil,
		)
		isInitiator = true
	} else if handshake.state == handshakeResponseCreated {
		KDF2(
			// 发送方基于 chainKey 衍生出发送和接收所需的对称加密密钥
			&recvKey,
			&sendKey,
			handshake.chainKey[:],
			nil,
		)
		isInitiator = false
	} else {
		return fmt.Errorf("invalid state for keypair derivation: %v", handshake.state)
	}

	// ...

	keypair := new(Keypair)
	// 发送和接收使用不同 key 的 AEAD
	keypair.send, _ = chacha20poly1305.New(sendKey[:])
	keypair.receive, _ = chacha20poly1305.New(recvKey[:])

	// ...
}

上面的代码包含了如何从相同的 chainKey 衍生出发送和接收所需的独立的 AEAD 的密钥。上面的逻辑中,可以看到初始方的发送数据所需要的 sendKey 和响应方所需的 recvKey 是相同的,初始方接收数据所需的 recvKey 和响应方所需的 sendKey 是相同的。

chainKey 是什么?

type Handshake struct {
	// ...
	chainKey                  [blake2s.Size]byte       // chain key
	// ...
}

可以看到是 Handshake 类型的字段。

初始方初始化 chainKey 的逻辑:

func init() {
	InitialChainKey = blake2s.Sum256([]byte(NoiseConstruction))
	mixHash(&InitialHash, &InitialChainKey, []byte(WGIdentifier))
}
func mixKey(dst, c *[blake2s.Size]byte, data []byte) {
	KDF1(dst, c[:], data)
}
func (h *Handshake) mixKey(data []byte) {
	mixKey(&h.chainKey, &h.chainKey, data)
}
func (device *Device) CreateMessageInitiation(peer *Peer) (*MessageInitiation, error) {
	// ...
	handshake.chainKey = InitialChainKey
	// ...
    // 初始方的临时公钥会被明文传输给响应方
	msg := MessageInitiation{
		Type:      MessageInitiationType,
		Ephemeral: handshake.localEphemeral.publicKey(),
	}

	handshake.mixKey(msg.Ephemeral[:])
	// ...
	// handshake.localEphemeral 是本地临时私钥
	// handshake.remoteStatic 是响应方静态公钥
	// triple DH 中的第一次,基于初始方临时私钥和响应方静态公钥
	// 如果要得到相同的 ss,响应方需要有静态私钥
	ss, err := handshake.localEphemeral.sharedSecret(handshake.remoteStatic)
	// 以 ss 为数据,计算出新的 chainKey 和 key
	KDF2(
		&handshake.chainKey,
		&key,
		handshake.chainKey[:],
		ss[:],
	)
	// ...
	// handshake.precomputedStaticStatic[:] 是初始方静态私钥和响应方静态公钥基于 DH 计算而来,
	// 是在 `wg set wg0 private-key` 和 `wg set wg0 peer` 的时候触发的,
	// 见 `SetPrivateKey` 和 `NewPeer` 函数,
	// 这里涉及 DH 计算
	KDF2(
		&handshake.chainKey,
		&key,
		handshake.chainKey[:],
		handshake.precomputedStaticStatic[:],
	)
	// ...
}

func (device *Device) ConsumeMessageResponse(msg *MessageResponse) *Peer {
	// ...
	// msg.Ephemeral[:] 是响应方临时公钥
	mixHash(&hash, &handshake.hash, msg.Ephemeral[:])
	mixKey(&chainKey, &handshake.chainKey, msg.Ephemeral[:])

	// triple DH 中的第二次,基于初始方临时私钥和响应方临时公钥
	ss, err := handshake.localEphemeral.sharedSecret(msg.Ephemeral)
	mixKey(&chainKey, &chainKey, ss[:])

	// triple DH 中的第三次,基于初始方静态私钥和响应方临时公钥
	// 这里是最后一次改变 chainKey
	ss, err = device.staticIdentity.privateKey.sharedSecret(msg.Ephemeral)
	mixKey(&chainKey, &chainKey, ss[:])
	// ...
}

响应方初始化 chainKey 的逻辑:

func init() {
	InitialChainKey = blake2s.Sum256([]byte(NoiseConstruction))
	mixHash(&InitialHash, &InitialChainKey, []byte(WGIdentifier))
}
func mixKey(dst, c *[blake2s.Size]byte, data []byte) {
	KDF1(dst, c[:], data)
}
func (h *Handshake) mixKey(data []byte) {
	mixKey(&h.chainKey, &h.chainKey, data)
}
func (device *Device) ConsumeMessageInitiation(msg *MessageInitiation) *Peer {
	// ...
	mixHash(&hash, &InitialHash, device.staticIdentity.publicKey[:])
	mixHash(&hash, &hash, msg.Ephemeral[:])
	// msg.Ephemeral[:] 是初始方的临时公钥
	mixKey(&chainKey, &InitialChainKey, msg.Ephemeral[:])
	// ...
	// triple DH 第一次
	ss, err := device.staticIdentity.privateKey.sharedSecret(msg.Ephemeral)
	if err != nil {
		return nil
	}
	KDF2(&chainKey, &key, chainKey[:], ss[:])
	// ...
	KDF2(
		&chainKey,
		&key,
		chainKey[:],
		handshake.precomputedStaticStatic[:],
	)
	// ...
}
func (device *Device) CreateMessageResponse(peer *Peer) (*MessageResponse, error) {
	// ...
	handshake.localEphemeral, err = newPrivateKey()
	msg.Ephemeral = handshake.localEphemeral.publicKey()
	handshake.mixHash(msg.Ephemeral[:])
	handshake.mixKey(msg.Ephemeral[:])

	// triple DH 第二次
	ss, err := handshake.localEphemeral.sharedSecret(handshake.remoteEphemeral)
	handshake.mixKey(ss[:])
	// triple DH 第三次
	ss, err = handshake.localEphemeral.sharedSecret(handshake.remoteStatic)
	handshake.mixKey(ss[:])
	// ...
}

为什么 chainKey 是相同的?

在基于 triple DH 等操作的结果进行密钥衍生的时候,由于 DH 的特性,实际上是在基于相同的输入数据进行 HMAC 认证码的计算。 所以最终的 chainKey 是相同的。

如何发送数据?

数据包外发的逻辑封装主要有以下的部分:

  1. 数据串行消费
  2. 数据并行加密
  3. 根据具体的 Peer,调用对应的实际封装(会有具体的目标地址)发送

主动外发数据包的场景如下:

  1. 首次建立连接,从 TUN 设备读取数据包内容,将请求发送给对端 Peer
  2. 开启保持连接时,按照指定的时间间隔,主动外发 Keepalive 包

这里面比较关键的几个函数:

func (peer *Peer) StagePackets(elems *QueueOutboundElementsContainer) {
   // ...
}

这个函数确保数据包暂存队列中数据包的新鲜度。在成功的握手建立安全的加密通信前,数据包都不应该被传输。默认缓存 128 个数据包,如果暂存队列满了,老的数据包将被清理,腾出位置给新的数据包。

func (peer *Peer) SendStagedPackets(){
	// ...
}

外发的数据包会通过这个函数实现并行加密和串行发送。

func (device *Device) RoutineReadFromTUN() {
	// ...
}

Linux 网络栈的数据包会发送给 TUN 设备,这个函数监听 TUN 设备,捕获系统数据包(然后就是暂存,排序,加密,最终外发给对端 Peer 节点)。

如何创建握手包?

代码中将握手需要的数据信息,以 Handshake 结构体进行管理。

初始方创建握手包,实际上就是初始化 Handshake 的实例,并根据 Handshake 实例的信息,初始化握手包对应的结构体 MessageInitiation

这块的逻辑在 func (device *Device) CreateMessageInitiation(peer *Peer) (*MessageInitiation, error) 函数内。

为什么初始握手包中的初始方公钥和时间戳需要使用不同的 DH 共享密钥进行加密?

  1. 初始方的公钥如何加密?是通过初始方的临时私钥和响应方的静态公钥,经过 curve25519 计算的临时密钥进行加密。
  2. 初始方的时间戳如何加密?是通过初始方的静态私钥和响应方的静态公钥,经过 curve25519 计算的临时密钥进行加密。
  3. 加密算法是?ChaCha20-Poly1305

在第一项中,初始方方临时公钥会被直接明文传输给响应方,响应方如果想成功解密,需要具备正确的响应方静态私钥。因此,从初始方的角度,这是对响应方的身份验证(只有正确的响应方,才能解密,拿到我的静态公钥)。

在第二项中,加密是基于初始方的静态私钥。从响应方的角度是对初始方身份的验证,从初始方的角度则是对响应方身份的验证。 第二项实际上要求了时间戳来自正确的初始方,并且只对正确的响应方可见。因为在响应方的逻辑中,会基于时间戳来判断是否是重放攻击(前后两个来自初始方的时间戳的比较),是否是基于握手包的洪水攻击(每秒钟发送的包的频率超过 50 个)。

临时密钥对何时会被更新?

临时密钥对数据包生命周期(3分钟)和最大数据包量有限制。

WireGuard 是否依赖时间准确度?

不依赖。

判断重放攻击基于初始方的时间戳(前后两个请求的时间戳间隔,都是来自初始方)。

判断握手包洪水攻击基于响应方的时间戳(前后两次消费握手包的时间戳间隔,都是来自响应方本地)。

握手包中的 msg.mac1 如何计算?

msg.mac1 是基于 blake2s 计算(含加密参数,加密参数来自响应方公钥)的,对初始方握手包二进制数据的哈希。

为何会发生初始方 DoS 攻击?

curve25519 计算共享密钥的操作对 CPU 是一个高成本的操作。初始方在创建初始握手包会执行 2 次基于 curve25519 计算共享密钥的操作。如果初始方不对 MessageCookieReplyType 的合法性进行验证,收到即创建新的初始握手包,本地就会出现大量的 curve25519 计算,导致 CPU 侧资源耗尽,DoS 攻击生效。

如何避免初始方被 DoS 攻击?

这块的逻辑在初始方消费响应握手包的时候

逻辑如下:

func (st *CookieGenerator) ConsumeReply(msg *MessageCookieReply) bool {

	//...

	xchapoly, _ := chacha20poly1305.NewX(st.mac2.encryptionKey[:])
	// 通过 AEAD 确保 st.mac2.lastMAC1[:] 没有被篡改
	// msg.mac1 是初始握手包的哈希,通过这个方式确保响应包和初始握手包一一对应的关系
	_, err := xchapoly.Open(cookie[:0], msg.Nonce[:], msg.Cookie[:], st.mac2.lastMAC1[:])
	if err != nil {
		return false
	}

	st.mac2.cookieSet = time.Now()
	st.mac2.cookie = cookie
	return true
}

当响应方响应 MessageCookieReplyType 类型握手包给初始方的时候,初始方根据 32 字节的密钥和 Nonce 计算 HMAC 的密钥(前 16 字节),基于 HMAC 密钥和 msg.mac1 计算 16 字节结果,跟密文 [n-16, n] 的结果比较,一致说明这个响应包没有被伪造。因为产生 HMAC 密钥需要有 32 字节的 AEAD 密钥(实际上是响应方的公钥),攻击者尝试伪造 MessageCookieReplyType 响应握手包时,因为没有 AEAD 密钥,进而无法得到 HMAC 密钥,所以密文 [n-16, n] 中的内容就无法伪造了。这样就确保了:

  1. 只有知道响应方公钥的初始方,才能发起初始握手请求(实现响应方的隐蔽性)
  2. 只有响应方创建的 MessageCookieReplyType 响应握手包才会被初始方接受(避免初始方被 DoS 攻击)

如何保持连接?

WireGuard 初始端如果开启了 Keepalive,会在首次启动(Keepalive 的值从 0 变为非 0)时,主动向响应端发保活的空数据包,假设初始端在 NAT 后面,NAT 会建立一个响应端 IP 和端口与初始端 IP 和端口的记录。

又因为内部定时器会按照 Keepalive 指定的时间间隔,定时发送保活的空数据包,因此,即使遇到响应端网络故障,中间的 NAT 设备的记录也会因为定时的刷新而保持长期生效。

也就是说,即使是因为 MTU 等潜在的问题导致 1 个小时的网络丢包,网络恢复后,WireGuard 公网到内网的连接也会自动恢复。

如果首次连接(或链路不通),因为临时密钥刚创建(或 180s 后过期),会触发握手流程,握手流程每隔 5s 重试一次,直到 18 次的最大允许次数(放弃重试,不会再有初始握手包发出),于是,下一个 Keepalive 包被创建,并再次触发握手流程,这就是实际场景里面会发生的事情。

如果初始方在 NAT 后,响应方是公网服务器(有固定的公网地址),此时双方都开启 Keepalive(保活),会产生哪些数据包?

  1. 双方会在设置 Peer 公钥时,开始发送 Keepalive 包。
  2. 双方发出 Keepalive 的操作,会触发握手流程。
  3. 双方都会创建初始握手包,并尝试发送。

场景一:假设响应方先尝试发出,由于缺少初始方的端点地址 (Endpoint)信息,将报错导致握手重试,握手重试 18 次后,再次尝试发送保活包,再次触发握手流程,再次重复 18 次,循环反复。

func (peer *Peer) SendBuffers(buffers [][]byte) error {
	// ...
	peer.endpoint.Lock()
	endpoint := peer.endpoint.val
	if endpoint == nil {
		peer.endpoint.Unlock()
		return errors.New("no known endpoint for peer")
	}
	// ...
}

场景二:初始方先发起握手流程,因为响应方是公网服务器,流程顺利完成。

接下来初始方开始向响应方发送保活包,由于上一步的握手流程,响应方已拿到了初始方 NAT 的公网 IP 和端口2,因此双方都可以发起通信。

双方都在按照特定的时间间隔,向对方发送保活包。

如果初始方(NAT 后)的公网 IP 改变(家用带宽经常出现的情况),则初始方将出现短时间无法接收到响应方保活包的情况,且响应方自己也无法感知,这种情况会在可控的时间内恢复。原因是:

  1. 临时密钥对过期,触发握手流程(最多 165s)。此时初始方重新发起握手流程。
  2. 因为初始方也开启了保活,当 IP 变化时,保活包携带的正确来源 IP 将更新响应方的端点地址信息。因此双方很快就恢复了双向通信。

这里有两点额外的信息:

  1. 发送的存活包,不会得到任何响应,即使丢包。
  2. 因为 Keepalive 机制,WireGuard 也具备内网穿透能力。

是否保证数据包顺序?

WireGuard 本身不保证数据包按序到达,这块需要顶层协议来保证。

如何确保 Nonce 的唯一性?

Nonce 本身是一个计数器值,在每次发送数据包的时候自增,这个自增的操作是一个原子性操作,Nonce 值会被用来保证数据的新鲜度(避免重复数据包和数据包过旧)。

见下面的代码:

func (peer *Peer) SendStagedPackets() {
top:
	// ...
	for {
		var elemsContainerOOO *QueueOutboundElementsContainer
		select {
		case elemsContainer := <-peer.queue.staged:
			i := 0
			for _, elem := range elemsContainer.elems {
				elem.peer = peer
				elem.nonce = keypair.sendNonce.Add(1) - 1
				if elem.nonce >= RejectAfterMessages {
				// ...
				} else {
					elemsContainer.elems[i] = elem
					i++
				}

				elem.keypair = keypair
			}
			elemsContainer.Lock()
			elemsContainer.elems = elemsContainer.elems[:i]

			// ...

			// add to parallel and sequential queue
			if peer.isRunning.Load() {
				peer.queue.outbound.c <- elemsContainer
				peer.device.queue.encryption.c <- elemsContainer
			} else {
            // ...
			}
			// ...
		default:
			return
		}
	}
}

密钥对更新的过程

何时创建?

  • 初始方收到握手响应包,开始密钥衍生时创建。
  • 响应方收到握手包,发送响应包时。

何时更新?

也就是什么时候会重新握手。

第一种:初始方在收到任何一个数据包后,都会判断数据包密钥对是否即将过期:

time.Since(keypair.created) > (RejectAfterTime-KeepaliveTimeout-RekeyTimeout)

如果即将过期(180 - 10 - 5 = 165s),初始方发起新的握手请求。

第二种:初始方在发送任何数据包后,判断数据包的计数器是否超过了允许的最大值,如果超过重新握手。

第三种:初始方在发送任何数据包后,判断数据包的计数器是否已经过期(180s):

(keypair.isInitiator && time.Since(keypair.created) > RekeyAfterTime)

如果已过期重新握手。

第四种:握手失败后重试。初始方发送初始握手包,会通过定时器等待 RekeyTimeout (5s),如果收到握手响应包,定时器停止,否则重新发送握手包,最多重发 18 次。

第五种:发送数据包后没有响应包的场景。在每次发送数据包后,会在 KeepaliveTimeout + RekeyTimeout (10s + 5s),后重新开始握手流程。响应方默认在没有数据返回时,会在收到数据包的 10s 后,发送一个空数据包(保活包),表明发送方没有数据需要返回。发送方需要在 15s 内收到数据包或保活包。

第六种:在发送任何一个数据包前,如果当前密钥对超过了允许的最大消息数,则重新握手。

第七种:在发送任何一个数据包前,如果当前密钥对超过了允许的使用时间 RejectAfterTime(180s),则重新握手。接收方本地的密钥对如果 RejectAfterTime(180s)内没有被更新,后续的数据包将被丢弃。

如何更新?

初始方的密钥轮替状态。开始前有几个点需要知道。

  1. 密钥轮替轮替的是临时的对称加密密钥。用新的临时密钥对替换旧的。
  2. 密钥对(Keypair)指的是收、发时使用的两个临时的对称加密密钥,而不是指任何非对称加密的密钥。
  3. 密钥轮替的目的是为了实现前向保密性3
  4. 可能会出现旧的密钥对和新的密钥对都有效的情况。因为在旧的密钥对过期前,WireGuard 会提前(15s)开始握手流程,避免因为密钥过期导致的丢包。

密钥轮替通过 Peer 结构体的 keypairs 字段(也就是密钥对集合)来记录,代码如果:

type Peer struct {
	// ...
	keypairs          Keypairs
	// ...
}

type Keypairs struct {
	sync.RWMutex
	current  *Keypair
	previous *Keypair
	next     atomic.Pointer[Keypair]
}

初始方创建初始握手包,此时会创建新的索引表的索引 ID、Keypair 实例,更新索引表(用新索引 ID 替代旧索引 ID),在基于非对称加密衍生对称加密的临时密钥时,更新索引表(用新 Keypair 替代旧 Keypair),将 Keypair 实例指针赋值给初始方 Peer.keypairs 的 current 字段。

响应方收到握手包,开始创建握手的响应包,类似初始方,此时会创建新的索引表的索引 ID、Keypair 实例,更新索引表(用新索引 ID 替代旧索引 ID),在基于非对称加密衍生对称加密的临时密钥时,更新索引表(用新 Keypair 替代旧 Keypair),将 Keypair 实例存储在响应方 Peer.keypairs 的 next 字段,将 previous 设为 nil。

初始方消费握手的响应包,握手成功,并返回一个空的数据包(保活包)。

这里插入第一个问题,初始方发送数据包和发送握手包的两个操作,是串行的还是并行的?

第一种场景,初始方收到数据包准备发送给响应方,由于未完成握手,本地缺失临时的对称加密密钥,因此会进入握手流程。因此是串行的。

第二种场景,之前已经成功握手并基于开始传输数据,初始方在接收数据包时发现,临时的对称加密密钥即将过期(已经存在 165s 以上),因此在接收数据包的逻辑的末尾,会进入握手流程。因此是串行的。

第三种场景,初始方向响应方发送数据包后,发现临时的对称加密密钥已过期(已经存在时间超过 180s 以上),在发送了数据包后,开始握手流程。因此是串行的。

可以看到,所有的场景下,初始方发送数据包和发送握手包,都是串行的。回到之前探讨的问题(密钥轮替的机制)。

当响应方收到保活包,此时响应方的 Peer.keypairs 的 next 是最新的 Keypair,这里将 Keypair 的指针更新到 Peer.keypairs 的 current 字段,并将 Peer.keypairs 的 next 设为 nil。

当临时的对称密钥即将过期或已过期时,初始方开始新的握手流程。

分为几种场景:

第一种,初始方还是之前的初始方。

此时 Peer.keypairs 的 current 是上一次握手对应的 Keypair,这里将 current 中的值保存到 previous 内,然后存储新创建的 Keypair 的指针值。

响应方收到初始握手包,创建新的 Keypair,并更新 Peer.keypairs 的 next 的值为新 Keypair 的指针值。

响应方收到第二次握手后,发送一个保活包,将 Peer.keypairs 的 current 中的值赋值给 previous,将 next 中的值赋值给 current,将 next 设为 nil。

第二种,初始方是之前的响应方(B),响应方成为了初始方(A)。

这种转换会发生在:

  1. 响应方发送了大量的数据包,超过了当前 Keypair 允许的最大数据包数,触发握手。
  2. 响应端发送了数据包,但 15s 内没有收到存活包(如果初始方没有更多的数据需要发送给响应方,会发送一个存活包,表示连接将关闭)或者其他有效的来自初始方的数据包,触发握手流程。

B 发起握手流程,A 收到初始握手包,创建新的 Keypair,并更新 Peer.keypairs 的 next 的值为新 Keypair 的指针值,并将 previous 设为 nil。

B 收到握手响应包,创建新的 Keypair,并更新 Peer.keypairs 的 current 的值为新 Keypair 的指针值。

B 发送一个保活包,A 收到数据包,将 Keypair 的指针更新到 Peer.keypairs 的 current 字段,并将 next 设为 nil。

特殊情况讨论,什么情况下初始方的 next 不为 nil?

响应方收到初始握手包,但还没有收到初始方的存活包,此时响应方也发起了握手请求,此时 Peer.keypairs 的 next 不为 nil。

此时,会以响应方发起的握手流程作为优先考虑。

初始方在向响应方发送了初始握手包,收到响应握手包后,又收到了来自响应方的初始握手包。

此时,初始方也以新的握手流程为优先考虑。

定时器与状态

定时器用于协议内状态的管理,WireGuard 是一个有内部状态的协议(但追求外部的无状态特性)。

在下面的函数中初始化了定时器:

func (peer *Peer) timersInit() {
	peer.timers.retransmitHandshake = peer.NewTimer(expiredRetransmitHandshake)
	peer.timers.sendKeepalive = peer.NewTimer(expiredSendKeepalive)
	peer.timers.newHandshake = peer.NewTimer(expiredNewHandshake)
	peer.timers.zeroKeyMaterial = peer.NewTimer(expiredZeroKeyMaterial)
	peer.timers.persistentKeepalive = peer.NewTimer(expiredPersistentKeepalive)
}

retransmitHandshake 定时器的作用是?

在握手包被发送后,倒计时 5s,如果收到该握手包的响应包,定时器会被停止。

sendKeepalive 定时器的作用是?

如果我们只接收数据,但是不返回任何响应持续很长一段时间,会被中间的网络设备或代理认为是连接超时(读超时或者写超时)。通过在收到数据包的 KeepaliveTimeout 秒(10s)后发送一个存活包,从而维持链路中网络设备的状态,这对于接收方是在 NAT 设备后的场景是非常重要的,因为 NAT 后的设备,只有主动的发出封包,中间的 NAT 设备才会保持内网 IP 和端口与公网 IP 和端口的映射关系。

newHandshake 定时器的作用是?

这个是跟上面的场景配套的发送方的逻辑。发送方长时间的发送数据。如果在发出数据包后 15s 内没有收到任何响应的数据包或者存活包(接收端即使是只接收不发送,10s 后也会发存活包的,因此,链路正常的情况下,发送方一定会在 15s 内收到一个包),那么发送方重新开始握手。

zeroKeyMaterial 定时器的作用是?

在每次密钥衍生结束时开始这个定时器,衍生密钥实际上会创建特定 Peer 的 Keypair,并在索引表内记录 Peer、Keypair、Handshake 和数据包 ID 的关系。这个定时器的作用是在 RejectAfterTime * 3 (180s * 3 = 540s = 9 min)后,清理服务端所有的衍生临时密钥的材料。因为每 3s 会重新握手并衍生新的临时密钥,因此,这个定时器只会在长期断网时被触发。

对于开启了 Keepalive 的场景,因为本地会定时发送保活包,保活包外发的时候会触发握手流程,进而触发密钥衍生的流程,因此,这个定时器永远不会被执行。

persistentKeepalive 定时器的作用是?

开启 Keepalive 的时候,WireGuard 会在设置 Peer 公钥时主动发送一个空数据包。

这个定时器的目的是根据用户开启 Keepalive 时指定的时间为间隔,在没有任何数据包发出时,按照该时间间隔,定时发出空数据包(存活包),从而保证链路(比如 NAT 设备)的连接状态。

定时器中有哪些锁?

// A Timer manages time-based aspects of the WireGuard protocol.
// Timer roughly copies the interface of the Linux kernel's struct timer_list.
type Timer struct {
	*time.Timer
	modifyingLock sync.RWMutex
	runningLock   sync.Mutex
	isPending     bool
}

有两个锁,分别是表示修改状态的锁(读写锁)和运行状态的锁(互斥锁)。

表示修改状态的锁是一个读写锁,允许并发读 isPending 变量来判断当前的定时器是否已经启动(未触发),在条件满足时,通过锁实现原子修改,修改 isPending 的值和对应定时器的状态。

func (timer *Timer) IsPending() bool {
	// 允许并发读
	timer.modifyingLock.RLock()
	defer timer.modifyingLock.RUnlock()
	return timer.isPending
}

func (peer *Peer) NewTimer(expirationFunction func(*Peer)) *Timer {
	timer := &Timer{}
	timer.Timer = time.AfterFunc(time.Hour, func() {
		timer.runningLock.Lock()
		defer timer.runningLock.Unlock()

		// 获取写锁
		timer.modifyingLock.Lock()
		// 定时器被触发时,定时器的状态变量需要从 true 重置为 false
		// 如果已经是 false,则不修改,直接释放锁,这里的作用见后面的表示运行状态的锁(实现同步删除)
		if !timer.isPending {
			timer.modifyingLock.Unlock()
			return
		}
		// 否则修改改变量后再释放锁
		timer.isPending = false
		timer.modifyingLock.Unlock()

		expirationFunction(peer)
	})
	timer.Stop()
	return timer
}

func (timer *Timer) Mod(d time.Duration) {
	// 当条件满足的时候,调用定时器的 Mod 方法,将表示状态的变量从 false 改为 true
	// 然后重置计时器,开始新的一轮的倒计时
	timer.modifyingLock.Lock()
	timer.isPending = true
	timer.Reset(d)
	timer.modifyingLock.Unlock()
}

func (timer *Timer) Del() {
	// 在条件满足的时候,调用定时器的 Del 方法,将启动的定时器表示状态的变量从 true 改为 false
	// 然后停止计时器(此时定时器启动,但未触发)
	timer.modifyingLock.Lock()
	timer.isPending = false
	timer.Stop()
	timer.modifyingLock.Unlock()
}

表示运行状态的锁是一个互斥锁,该锁会在如下的场景被获取:

  1. 定时器被触发(倒计时结束时),过期函数被执行的时候会获取锁,函数中的逻辑执行完成后释放锁。
  2. 当一个 Peer 被删除或替换,或者 Tun 设备被关闭(会在关闭前删除所有的 Peer),旧 Peer 所有的定时器会被同步删除(实现同步删除会用到)。

这是第一项对应的代码:

func (peer *Peer) NewTimer(expirationFunction func(*Peer)) *Timer {
	timer := &Timer{}
	timer.Timer = time.AfterFunc(time.Hour, func() {
		// 触发时获取锁
		timer.runningLock.Lock()
		// 退出时释放锁
		defer timer.runningLock.Unlock()

		timer.modifyingLock.Lock()
		if !timer.isPending {
			timer.modifyingLock.Unlock()
			return
		}
		timer.isPending = false
		timer.modifyingLock.Unlock()

		expirationFunction(peer)
	})
	timer.Stop()
	return timer
}

这是第二项对应的代码:

func (timer *Timer) Del() {
	timer.modifyingLock.Lock()
	timer.isPending = false
	timer.Stop()
	timer.modifyingLock.Unlock()
}

func (timer *Timer) DelSync() {
	// 不等待锁,马上尝试原子修改,将定时器停下来,不用等待锁可以提高性能
	// 正在运行但是还没触发的定时器会被马上停止
	timer.Del()
	// 同步删除保证:
	// 1)在停止前被触发的定时器,定时器对应的过期逻辑会被执行后,定时器关闭的操作才会被执行。
	// 2)在停止操作执行后,停止操作这里的 runningLock 被成功获取(下面这个 Del 方法执行期间),这之后触发的定时器,过期逻辑会被跳过。
	//
	// 这里的逻辑是:Del 方法将 isPending 修改为 false,当 runningLock 互斥锁被释放后,在调用过期函数前,会判断 iisPending 是否为 false,如果是直接跳过,见下一个代码块。
	timer.runningLock.Lock()
	timer.Del()
	timer.runningLock.Unlock()
}
func (peer *Peer) NewTimer(expirationFunction func(*Peer)) *Timer {
	timer := &Timer{}
	timer.Timer = time.AfterFunc(time.Hour, func() {
		timer.runningLock.Lock()
		defer timer.runningLock.Unlock()

		timer.modifyingLock.Lock()
		// 这里确保后面的 `expirationFunction` 不会再被触发,因为 Del 方法将 isPending 设为了 false,直接释放锁并 return 了。
		if !timer.isPending {
			timer.modifyingLock.Unlock()
			return
		}
		timer.isPending = false
		timer.modifyingLock.Unlock()

		expirationFunction(peer)
	})
	timer.Stop()
	return timer
}

更新端点的场景

初始方何时更新响应方的端点(Endpoint)地址?

第一种,通过配置文件,指定响应方的端点地址:

[Interface]
PrivateKey = WMWozvjIgpA2h75juoku2btWxbJ54i4Yt0A0RhpW7V8=
ListenPort = 51820
Address = 10.70.0.4/16
MTU = 1280

[Peer]
PublicKey = CmmeC0yqofMMZhEGHuK5dd2Mxyxe7tA8wSniDWiI5V0=
PresharedKey = KKQGN01IkZ1kJD2fAxtDZ6k5VFAI2fMca2q+SV7OrGE=
# 这里指定了响应方的公网地址和端口
Endpoint = 39.101.166.124:51820
AllowedIPs = 10.70.0.0/16
PersistentKeepalive = 15

第二种,通过命令行指定:

wg set demo peer CmmeC0yqofMMZhEGHuK5dd2Mxyxe7tA8wSniDWiI5V0= endpoint 39.101.166.124:51820

第三种,当成功的收到握手响应包后,包的来源 IP 将被设置为端点地址(外发的数据包的目标地址)。

第四种,当成功的收到有效的数据包,包的来源 IP 将被设置为端点地址(外发的数据包的目标地址)。

响应方何时更新初始方的端点地址?

  1. 成功的收到初始响应包时。将来源 IP 设置为端点地址。
  2. 握手结束,收到第一个数据包时。将来源 IP 设置为端点地址。
  3. 每次通过系统调用,读取到多个响应包,处理完最后一个有效的数据包时。将来源 IP 设置为端点地址。

特殊场景

双方之前已经成功握手(距离上一次成功握手的时间小于 180s),此时初始方重新创建 WireGuard 设备(公钥和私钥不变,重启设备),此时初始方临时的对称加密密钥会被清理,如果响应方此时发送数据包给初始方,会发生什么?

响应方会基于旧的临时的对称加密密钥加密数据并发出,每个数据包被发出后,会触发定时器:

/* Should be called after an authenticated data packet is sent. */
func (peer *Peer) timersDataSent() {
	if peer.timersActive() && !peer.timers.newHandshake.IsPending() {
		peer.timers.newHandshake.Mod(KeepaliveTimeout + RekeyTimeout + time.Millisecond*time.Duration(fastrandn(RekeyTimeoutJitterMaxMs)))
	}
}

// 这个是定时器到期后执行的函数
func expiredNewHandshake(peer *Peer) {
	peer.device.log.Verbosef("%s - Retrying handshake because we stopped hearing back after %d seconds", peer, int((KeepaliveTimeout + RekeyTimeout).Seconds()))
	/* We clear the endpoint address src address, in case this is the cause of trouble. */
	peer.markEndpointSrcForClearing()
	// 这里开始新的握手流程
	peer.SendHandshakeInitiation(false)
}

在 15s 内如果没有收到任何响应,会重新开始握手流程。

PS:该数据包会丢失,依赖上层协议的完整性验证来重传。

握手流程中,初始方在收到响应方的握手响应包后,为何要在完成响应包消费后,向初始方发送保活包?

响应方在发送完响应握手包后,会期待来自初始方的存活包(可以认为是数据包的一种,是个空数据包),通过它告知响应方握手完成。握手完成会更新 peer.timers.retransmitHandshake 定时器,重置跟握手重试相关的原子变量,为下次握手做准备。

在上面的问题中,出现了数据包丢失,WireGuard 中如何避免这种丢失?

上面的丢失实际上是因为使用了失效的临时的对称加密密钥。WireGuard 在接收到任何一个数据包时,会判断临时的对称加密密钥是否即将过期(只剩 15s 有效期),如果是,会主动发起握手流程,协商新的临时的对称加密密钥。通过提前握手,规避旧密钥导致的丢包问题。

UAPI socket 的管理

UAPI socket 用于实现 wg 命令管理 WireGuard 进程。wg 命令会向 UAPI socket 建立连接并发送控制事件(WireGuard 配置管理协议),控制事件以两个换行符 \n\n 作为一个操作的结束,比如:listen_port=51820\n\nendpoint=1.1.1.1\n\n 就是两个控制事件,分别是配置 listen_port 和配置 endpoint

UAPI 基于 unix domain socket 进行通信,会有一个本地的 socket 文件,该 socket 文件通过 inotify 来捕获 socket 文件被删除的操作。

这个 Unix Domain Socket 对应的文件描述符将被用于配置管理协议的通信。

参考


  1. 重放攻击是一种网络攻击方式,攻击者通过截获合法的数据传输并重新发送(重放)这些数据来试图欺骗接收系统。目的通常是为了非法获得访问权限或者引起系统的不正当行为。重放攻击的关键在于攻击者并不需要解密或了解数据的具体内容,仅仅通过重复发送已有的数据包就可能实现攻击。防御措施通常包括使用时间戳、序列号或一次性令牌等手段来确保数据的新鲜度,从而使得重放的数据包可以被识别并拒绝。 ↩︎

  2. WireGuard 会自动更新返回响应包的目标地址,为最后一次有效的网络包的来源地址。见:更新端点的场景 ↩︎

  3. 关于前向保密性。假设攻击者通过专业设备记录了我们所有的加密流量,并在未来的某天拿到了我们的非对称加密的密钥(公钥和私钥),因为临时密钥每 3 分钟(基于非对称加密密钥)就重新协商生成,并只使用一次,攻击者无法破译这些过去的加密流量,即为前向保密。 ↩︎