跳到主要内容

10.4 ESP 实现 (IPv4)

我们已经看到了 XFRM 框架是如何存放策略(SPD)和状态(SAD)的——这就像是盖好了房子、备好了账本。但房子里的人怎么进出,账本里的规则怎么执行,取决于具体的协议。

这就是 ESP(Encapsulating Security Payload)登场的时刻。

上一节我们讨论了 XFRM 的骨架,现在我们把肉填进去。ESP 是 IPsec 协议族里最常用的那个,因为它既负责加密,又负责认证。它的协议编号是 50(IPPROTO_ESP)。

ESP 协议视图

在深入内核代码之前,我们需要先看清 ESP 数据包长什么样。

根据 RFC 4303 的定义,ESP 会在每个数据包上挂一个新的头和一个尾。这跟那种只改头的协议不一样,它是个“夹心饼干”。如果你抓包看一个经过 ESP 处理的 IPv4 数据包,你会看到结构是这样的(图 10-1):

+------------+-----+-----------+--------+---+----+------------------+
| SPI (4B) | Seq | Payload | Padding| Pad|Next| Auth Data (ICV) |
| (Header) | No. | (Data) | | Len|Hdr | (Trailer) |
+------------+-----+-----------+--------+---+----+------------------+

这几个字段值得你盯着看几眼:

  • SPI (Security Parameter Index):32 位。这是打开某个特定 SA 的钥匙。它必须配合源 IP 地址使用,才能唯一标识一个 SA(还记得上一节我们说 SA 是由三元组唯一确定的吗?这就是其中一员)。
  • Sequence Number (序列号):32 位。每发送一个包,这个数就加 1。它不仅是个计数器,更是为了防止重放攻击——接收端会维护一个滑动窗口,如果收到的包序列号太旧或者是重复的,直接丢掉。攻击者截获了一个合法包想重发?没门。
  • Payload Data (载荷数据):这是我们要传输的真正数据,现在是加密的乱码。
  • Padding (填充):0 到 255 字节。加密算法通常要求数据长度必须是某个块大小的整数倍(比如 AES 的 16 字节),如果不够就得填上。
  • Pad Length (填充长度):告诉接收端填了多少字节,解密后要把这些垃圾切掉。
  • Next Header (下一个头):1 字节。解密后,里面装的是什么协议?是 TCP、UDP 还是 ICMP?看这个字段。
  • Authentication Data (认证数据):ICV(Integrity Check Value)。这就是防篡改的指纹。发送方算好填进去,接收方算一遍比对,如果不一致,说明包被改过,直接丢弃。

关于性能:加密与认证的演进

虽然 ESP 支持只加密或只认证,但在现实世界里,几乎没人敢那样用。为了安全,通常是“既加密又认证”。传统的做法是先用加密算法(如 AES-CBC)处理数据,再用 HMAC(如 SHA1/SHA2)算一遍 ICV。这意味着要遍历数据两遍。

但硬件在进化。现在的内核更倾向于使用 AEAD(Authenticated Encryption with Associated Data) 算法,比如 AES-GCM。这种算法把加密和认证合并在一次操作里完成,不仅效率高,而且极其容易并行化。如果你的 CPU 支持 Intel AES-NI 指令集,IPsec 的吞吐量跑到好几 Gbit/s 是轻轻松松的事——这属于降维打击。

IPv4 ESP 的初始化

好了,协议本身长什么样我们心里有数了。现在的问题是:内核是怎么知道“收到协议号为 50 的包就交给 ESP 处理”的?

这是一个经典的双向注册过程:

  1. 告诉 XFRM 框架:“我是 ESP,我有这些处理函数(输入、输出、初始化状态等)。”
  2. 告诉 IPv4 协议栈:“如果收到协议号是 50 的包,请调用这个钩子函数。”

这两件事都是在 esp4_init() 函数里完成的,位于 net/ipv4/esp4.c

第一步:定义 ESP 类型 (xfrm_type)

首先,我们定义了一个 xfrm_type 结构体实例 esp_type

static const struct xfrm_type esp_type =
{
.description = "ESP4",
.owner = THIS_MODULE,
.proto = IPPROTO_ESP,
.flags = XFRM_TYPE_REPLAY_PROT,
.init_state = esp_init_state,
.destructor = esp_destroy,
.get_mtu = esp4_get_mtu,
.input = esp_input,
.output = esp4_output
};

这个结构体就像是 ESP 的“功能说明书”。

  • .proto 声明了自己处理的是 ESP 协议。
  • .flags 这里设置了 XFRM_TYPE_REPLAY_PROT,明确告诉内核:“我具备防重放攻击的能力”。
  • 最重要的是后面几个回调函数:
    • .input.output:这是数据包处理的核心逻辑。
    • .init_state:当用户空间通过 Netlink 创建一个新的 SA(比如密钥协商完成)时,这个函数会被调用来初始化 ESP 私有的上下文(比如分配加密算法所需的密钥内存)。

接下来,我们要把这个 esp_type 注册到 XFRM 框架里。

每个协议族(IPv4 或 IPv6)都有一个 xfrm_state_afinfo 对象。这个对象里有一个数组叫 type_map。注册的过程,实际上就是把这个 esp_type 挂到 IPv4 的那个 type_map 数组里去。

代码里是这样干的:

if (xfrm_register_type(&esp_type, AF_INET) < 0) {
pr_info("%s: can't add xfrm type\n", __func__);
return -EAGAIN;
}

如果这一步成功了,内核就记住了:“哦,处理 IPv4 的 ESP 协议时,要用 esp_type 里定义的这套逻辑。”

第二步:注册协议处理器 (net_protocol)

光让 XFRM 知道还不行。数据包从网卡进来,走到 IPv4 协议栈层的时候,它根本不关心什么 XFRM,它只看 IP 头里的协议号。

我们需要告诉内核:“如果你看到 IP 头里的 Protocol 字段是 IPPROTO_ESP (50),就把这个包扔给 xfrm4_rcv 函数。”

这是通过标准的 IP 协议注册机制完成的:

static const struct net_protocol esp4_protocol = {
.handler = xfrm4_rcv,
.err_handler = esp4_err,
.no_policy = 1,
.netns_ok = 1,
};
if (inet_add_protocol(&esp4_protocol, IPPROTO_ESP) < 0) {
pr_info("%s: can't add protocol\n", __func__);
xfrm_unregister_type(&esp_type, AF_INET);
return -EAGAIN;
}

注意这里的 互斥处理: 如果 inet_add_protocol 失败了(比如协议号 50 已经被别人占用了),代码会回滚之前的操作——调用 xfrm_unregister_type 把刚才注册的 esp_type 给卸载掉。这种“一来一回都要干干净净”的风格,是内核模块注册的标准范本。

一个有趣的细节:

你可能注意到了,这里的 .handler 并不是 ESP 独有的 esp_input,而是通用的 xfrm4_rcv

其实不仅是 ESP,IPv4 AH 协议net/ipv4/ah4.c)和 IPCOMP 协议net/ipv4/ipcomp.c)也都把 xfrm4_rcv 注册为自己的 handler。这是因为这三个协议在接收路径上的很多逻辑是通用的(比如查 SAD、查策略、处理重放窗口),内核没必要写三遍。xfrm4_rcv 会先处理这些杂事,最后再根据协议类型,跳到对应 xfrm_type 里的 .input 回调函数(也就是我们上面定义的 esp_input)去处理真正的解密逻辑。

到这里,初始化就完成了。管道铺好了,剩下的就是等待数据包的流动。