WireGuard 协议

本页目录

WireGuard是基于UDP的三层加密隧道,把peer公钥与允许的IP前缀绑定。它简化了算法选择与会话管理接口,但内部仍有握手状态、密钥代际、计数器、重放窗口和计时器,不能描述成“没有状态机,只有加密”。

它提供安全的数据平面,不包含完整的用户登录、设备审批、地址分配、DNS和NAT穿透控制平台。这些功能可由配置工具或其他系统补齐;有一对公钥并不意味着两台设备已经知道如何互相到达。

问题关键理解
AllowedIPs是路由还是访问控制发送按目的选peer,接收按源核验
148 B握手里有什么两个独立加密字段,不是一个合并块
120秒是否无流量也定期重握手与密钥年龄、角色和收发事件有关
keepalive是否让所有NAT都能穿透维持已有状态不等于自动建立可达路径
latest handshake很新为何业务不通还可能是内层路由、AllowedIPs或防火墙
MTU是否永远1420要按外层路径和封装预算计算

公钥身份、AllowedIPs 与外层 endpoint

peer公钥表示配置的加密身份;endpoint是当前发送UDP的IP和端口;AllowedIPs把内层IP范围关联到该身份。外层地址可改变,内层地址和公钥关系仍由本地策略决定。

AllowedIPs:发送选公钥,接收核对内层源地址 发送:目的 IP = 10.0.0.7 最长匹配 10.0.0.7/32 → Peer B 接收:已由 Peer A 认证解密 内层源 IP = 10.0.0.7 用 B 的会话密钥发送 还需外层 endpoint 可达 源地址归属 B,拒绝 A 冒用 加密有效不等于允许任意内层源地址 示例:A 拥有 10.0.0.0/24,B 拥有更具体的 10.0.0.7/32;仍需系统路由把包送到接口。

操作系统先依据路由把内层包交给WireGuard接口,接口再按目的地址在AllowedIPs中做最长前缀匹配,选择peer。接收侧先认证解密,再核对内层源地址是否归属该peer。仅能成功解密,不意味着该peer可以冒用其他内层地址。Cryptokey Routing

例如A配置10.0.0.0/24,B配置10.0.0.7/32:发往10.0.0.7选B,发往10.0.0.8选A;若A发送源地址10.0.0.7的内层包,在这种配置下会因更具体归属不符而拒绝。AllowedIPs不是简单对每个peer各自做独立集合包含测试。

from ipaddress import ip_address, ip_network

routes = [(ip_network('10.0.0.0/24'), 'A'), (ip_network('10.0.0.7/32'), 'B')]
def owner(address):
    ip = ip_address(address)
    candidates = [(net.prefixlen, peer) for net, peer in routes if ip in net]
    return max(candidates)[1] if candidates else None

assert owner('10.0.0.7') == 'B'
assert owner('10.0.0.8') == 'A'
assert owner('10.0.1.1') is None
assert owner('10.0.0.7') != 'A'  # A 不能在此配置下冒用 B 的内层源地址
print('最长前缀选择与接收源归属模型通过')

wg配置接口的WireGuard参数;地址和系统路由是其他配置层。wg-quick会按配置辅助设置地址与路由等,但这不意味着协议会自动把所有AllowedIPs广播给对端。0.0.0.0/0也不覆盖IPv6的::/0。wg、wg-quick

A允许整个/24,为什么不能发送其中的.7?

地址条中的金色.7被更具体的/32分给B。把接收peer保持A,来回移动.7与.8,结果在这一点发生变化;这来自统一的最长前缀归属,而非独立检查每个peer的允许集合。

正在呈现知识画面
A允许整个/24,为什么不能发送其中的.7?

最长前缀归属覆盖更宽的前缀。收到A认证包后,源地址属于B仍应拒绝,不能只检查A的/24包含它。

Noise 握手:固定算法组合,不是只做一次临时 DH

WireGuard采用Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s构造,组合静态与临时密钥建立认证和方向密钥;可选PSK作为额外材料混入。它不在每次连接中协商一长串cipher suite,但算法固定不等于协议之外不存在任何降级或配置风险。

发起方预先知道响应方静态公钥,响应方依据解出的发起者公钥识别已配置peer。双方只需交换公开身份材料,不能把“双方预共享”写成彼此知道对方静态私钥。

两条握手消息建立密钥,首条传输消息再向响应方确认 发起方 响应方 Initiation:148 B,临时公钥 + 加密身份 + 加密时间戳 Response:92 B,临时公钥 + 空明文的认证标签 Transport:数据或空 keepalive,确认新会话密钥 长度仅是 WireGuard 消息,不含 UDP/IP;cookie 回复属于按条件触发的另一条路径。

握手包含多次静态/临时DH及transcript混合,不能简化成一个临时DH结果再随意加上公钥哈希。正确的DH等式是DH(a_private, b_public) = DH(b_private, a_public);把public/public或private/private写进去不是另一种等价计算。

响应方发送第二条消息后,还要收到发起方用新密钥发来的Transport消息,才能获得该方向的密钥确认;发起方没有排队数据时可发空包完成这一步。WireGuard协议

握手消息的长度如何得到

消息字段预算总计
Initiation类型/保留4 + sender4 +临时公钥32 +加密静态48 +加密时间戳28 +mac1/mac2各16148 B
Response类型/保留4 +sender4 +receiver4 +临时公钥32 +空明文tag16 +mac1/mac2各1692 B
Cookie reply类型/保留4 +receiver4 +nonce24 +加密cookie3264 B

encrypted_static与encrypted_timestamp分别携带认证标签,不能合成一个“身份+时间戳”的单一AEAD字段。类型由1 B类型与3 B零保留位构成,内核以小端32位存储方便检查;索引和Transport counter同样按规定小端编码。

数据平面:IP 包、padding、计数器与认证标签

Transport消息头为16 B:类型/保留4、receiver index4、counter8;加密内容后有16 B标签,因此空keepalive的WireGuard消息为32 B。receiver index定位对应密钥状态,不是明文携带完整peer公钥。

内层IP包可能补零以减少精细长度泄漏。Linux发送路径通常按16 B对齐,但padding还受内层MTU等边界限制;不能一律在MTU顶端再补到下一个16倍数。以下计算单个正常内层包,不涉及GSO分段等复杂路径。

import struct

assert 4 + 4 + 32 + 48 + 28 + 16 + 16 == 148
assert 4 + 4 + 4 + 32 + 16 + 16 + 16 == 92
assert 4 + 4 + 24 + 32 == 64
header = struct.pack('<IIQ', 4, 0x12345678, 9)
assert len(header) == 16 and header.hex() == '04000000785634120900000000000000'

def outer_size(inner, mtu=1420, ip_header=40):
    assert 0 <= inner <= mtu
    padded = min(mtu, ((inner + 15) // 16) * 16)
    return ip_header + 8 + 16 + padded + 16

assert outer_size(0) == 80
assert outer_size(1000) == 1088  # 补至1008 B,外层IPv6
assert outer_size(1420) == 1500 # padding受MTU限制
assert outer_size(1000, ip_header=20) == 1068
print('空包 / 1000 B内层 / 1420 B内层的IPv6总长:', outer_size(0), outer_size(1000), outer_size(1420))

32 B是WireGuard消息下限,外层IPv4加UDP后为60 B,外层IPv6则80 B,不含链路层。1420常来自1500−40−8−32的预算,实际路径、IPv6扩展头或其他封装会改变可用值。

counter参与AEAD nonce构造,每个方向和密钥代际各有状态;接收侧用窗口容忍一定重排并拒绝重复。它不会像TCP一样重传丢失的内层数据包,可靠性仍由内层协议决定。WireGuard白皮书

乱序可以接受,重复为什么必须拒绝?

先递送7,再递送3:后者较小但在窗口内,仍可接受;再次递送3才是重复。递送15后窗口左界移到8,再来的7已经太旧。所有状态只在成功认证的包上推进。

正在呈现知识画面
乱序可以接受,重复为什么必须拒绝?

计数器小于最高值不一定是重放:还要看是否在窗口内、是否已收过。太旧或重复都拒绝,丢失位置不会自动触发重传。

计时器:按事件维护密钥,而非无条件每120秒重连

下面按Linux 7.2的drivers/net/wireguard/messages.h、timers.c、send.c和receive.c核对。网站概述中的简写单位或旧公式,不应覆盖具体实现的常量与触发条件。

条件或常量本地实现中的意义
REKEY_AFTER_TIME=120 s发起方发送路径按密钥年龄触发更新等
REJECT_AFTER_TIME=180 s超过相应密钥寿命后拒绝使用
REKEY_TIMEOUT=5 s握手未响应时重试基础间隔,再加jitter
KEEPALIVE_TIMEOUT=10 s收到数据后若没有回发,安排空包等事件行为
REKEY_AFTER_MESSAGES=2^60消息数量触发更新阈值,不是唯一安全限额
REJECT_AFTER_TIME×3=540 s相应静默条件下清理残余密钥材料的计时尺度

发送路径、接收路径和握手重试是不同触发点。当前接收路径的last-minute更新使用180−10−5=165秒这一年龄阈值,并带发起方角色等条件;不能照搬120−10−5,也不能解释成120秒没有流量就必定发起握手。

握手重试按5秒加随机抖动推进,并有尝试上限;这里不是5、10、20秒指数退避。默认没有PersistentKeepalive时,空闲设备可以保持安静,但此前流量触发的协议空包或重试仍可能存在。

新旧密钥可在过渡中共存,以适应丢包与重排;切换不等于立即抹除一切旧状态。固定消息阈值也不能换算成某个“多少万年后才换密钥”的使用保证,因为时间阈值和其他限制更早参与处理。

Cookie、漫游与 NAT 的边界

mac1帮助服务端在昂贵处理前筛选消息,但由公开响应方身份派生的相关材料不等于已经认证出一个获准peer。负载条件满足且缺少有效cookie时,可返回cookie挑战,要求后续mac2提供相应证明;不是每次首次握手都必定触发cookie。

mac2是使用cookie计算的MAC,不是直接把cookie原值塞进mac2字段。cookie有助验证来源可达并减少资源滥用,不能替代完整peer身份认证。响应消息的mac2也需按协议条件处理,不能永远标成零。

有效认证流量可更新peer的外层endpoint,支持地址变化后的漫游。但双方都没有可达端点、映射无法打通时,WireGuard本身没有自动协调发现或通用中继服务。

PersistentKeepalive用于让闲置时的NAT或状态防火墙映射持续有效,默认关闭,25秒是常见适用示例而非协议强制值。它维持可用路径,不保证任何NAT组合都能互通,也不能无限维持已经不可达的网络。WireGuard Quick Start

路由出口与诊断

全隧道路由时,应确保外层UDP去peer endpoint的流量有正常出口,避免再次送入同一WireGuard接口。策略mark与网络命名空间可帮助分开内层路由和外层socket所属网络环境。WireGuard路由与命名空间

以下选择性命令用于观察现有接口,不打印私钥或PSK,也不修改配置:

wg show all endpoints
wg show all allowed-ips
wg show all latest-handshakes
wg show all transfer
ip route show
ip -6 route show
现象区分的问题
没有近期握手是否有流量触发、endpoint是否可达、密钥是否匹配
握手成功但业务失败系统路由、AllowedIPs源校验、转发和防火墙
小包通、大包卡内层MTU、外层路径及ICMP反馈
空闲后被动入站失败NAT映射寿命与keepalive需求
漫游后单向通信外层endpoint更新、回程路径与过滤

latest-handshakes是时间记录,不是应用健康探针;长期空闲时旧时间不自动意味着故障。transfer计数增长也需结合方向和实际内层应用结果解释。

继续查阅