我构建 VPN 协议的故事:第1部分

发布: (2026年5月2日 GMT+8 06:21)
10 分钟阅读
原文: Dev.to

抱歉,我需要您提供要翻译的具体文本内容(文章正文)。请把您想要翻译的文字粘贴在这里,我会按照要求保留源链接并进行翻译。

免责声明

本文及 VPN 本身仅用于教育目的。

一切是如何开始的

我最近切换到了 Arch。一切进展顺利:我安装了所有需要的工具,然后决定安装我之前使用的 VPN。但出现了问题——它在 Arch 上无法工作(即使是 AppImage 也不行)。

我的供应商也支持 Shadowsocks,但我没有使用它,而是决定自己编写 VPN,以获得更多练习。

VPN 协议

我的 VPN 协议旨在实现最大隐蔽性。在我看来,这里最重要的因素之一是 从第一个数据包起就进行加密。在我的协议中,这一实现方式与 Shadowsocks 相同——使用预共享密钥。

  • 加密算法: ChaCha20‑Poly1305
  • 传输层: TCP(每个数据包都会添加随机数量的垃圾字节以进行长度混淆)

数据包结构

每个数据包都有一个 5 字节的头部,该头部使用 XOR 与密钥的前 5 字节进行加密掩码。

字节含义
前 2 字节总数据包长度 – 用于确定数据包的结束位置(因为 TCP 可能会对数据包进行分段)。
第 3 字节标志字节。目前仅使用两个标志:
位 1 – 表示该数据包是伪造的,不应被处理(尚未实现)。
位 2 – 用于执行 ECDH(椭圆曲线 Diffie‑Hellman)。
后 2 字节密文长度 – 用于将垃圾字节与密文分离。

在头部之后,数据包包含:

  1. 12 字节 – 随机生成的 nonce
  2. 密文
  3. AEAD(认证标签)
  4. 垃圾字节

握手与密钥交换

1. 来自客户端的第一个数据包

客户端向服务器发送其 16 字节的用户名(当然是加密的)。

2. 服务器响应

如果服务器找到具有该用户名的用户,它会:

  • 向客户端发送一个随机生成的 32 字节盐值
  • 开始计算密钥:
    • 发送密钥(服务器 → 客户端)
    • 接收密钥(服务器 ← 客户端)

3. 服务器上的密钥计算

服务器以明文形式存储用户的密码。

  • 接收密钥(用于解密来自客户端的数据) = hash(password + first 16 bytes of salt)
  • 发送密钥(用于加密发送给客户端的数据) = hash(password + last 16 bytes of salt)

4. 客户端操作

客户端接收盐值,解密后执行相同的操作,但密钥角色相反

  • 对服务器而言的 发送密钥 成为客户端的 接收密钥,反之亦然。

5. ECDH 与连接完成

  1. 客户端生成密钥后,基于 Curve25519 椭圆曲线创建一个 临时密钥对(用于 ECDH)。
  2. 然后它发送一个 连接确认0xFF)以及其公用临时密钥,并设置 ECDH 标志。

服务器接收该数据包,去混淆后获取确认信息和客户端的临时密钥。随后它:

  • 从本地私有网络为客户端分配一个 IP 地址
  • 生成自己的临时密钥对
  • 将分配的 IP 地址和服务器的公钥发送给客户端
  • 执行 ECDH 轮次

发送完毕后,服务器通过将旧密钥与 ECDH 获得的密钥进行哈希来更新密钥。

6. 客户端完成

当客户端收到包含 IP 地址和服务器公用临时密钥的数据包时,它会:

  • 创建本地隧道
  • 设置其 IP 地址(即从服务器收到的地址)
  • 执行 ECDH 轮次
  • 更新密钥

主工作循环

在连接建立并生成密钥后,主工作循环开始。

客户端

三个 goroutine 在客户端运行:

  1. 从隧道读取并准备数据包

    • 从隧道读取数据包。
    • 生成一个 8 字节盐,通过将旧的发送密钥与盐哈希来更新发送密钥。
    • 将该盐前置到明文前面(盐 + 隧道数据包)。
    • 对所有内容进行加密。
    • 添加随机垃圾字节以进行混淆。
    • 将准备好的数据包存入缓冲区。
  2. 发送数据包

    • 发送已经准备好的数据包。
    • 数据包以 1‑5 个为一批 发送(协议当然在 OSI 第 3 层和第 4 层被分段,但这超出我的控制范围)。
  3. 接收来自服务器的数据包

    • 接收服务器发来的数据包。
    • 执行去混淆和解密。
    • 将解密后的数据写入隧道。

服务器

服务器拥有 三个主要 goroutine,外加用于接收来自客户端数据包的额外 goroutine。

  1. 握手处理 – 处理传入的握手请求。如果握手成功,会 生成一个新的 goroutine 来处理该客户端的数据包。
  2. 从隧道读取 – 从隧道读取数据包并转发给客户端。
  3. 清理不活跃的连接 – 移除陈旧的连接。

关键更新

每个数据包中的盐

每个数据包(无论是客户端还是服务器)都包含一个 用于更新密钥:

  • 服务器(发送): 包含盐,然后通过将旧密钥与该盐哈希来更新其 发送密钥
  • 客户端(接收): 解密数据包后,使用相同的盐更新其 接收密钥

当客户端发送数据包时,角色互换(客户端更新其发送密钥,服务器更新其接收密钥)。

定期 ECDH 更新

4 分钟 发送 2³² 个数据包(以先到者为准)时,使用椭圆曲线的 ECDH 交换刷新密钥。新密钥与数据包一起传输。

实现

在实现过程中,我考虑使用 GoRust 编写。我选择 Go 因为它的简洁性。

协议描述结束。

On Process

说实话,这个协议架构大部分是在写代码的过程中才形成的。它存在不少问题——无论是协议设计还是实现方面。

示例问题

  • 固定的用户名数据包长度
    加密后的用户名数据包长度固定为44 字节(12 字节 nonce、16 字节密文和 16 字节 AEAD 标签)。只要知道这一点并且确认用户在使用该协议,就可以算出密钥的第 4、5 个字节。

  • 仓库重复
    我愚蠢地创建了两个独立的仓库——一个用于客户端,一个用于服务器。于是包含公共模块的分支相互重复。

  • Git flow
    我尝试遵循 Git Flow,但同样失败了。

  • 漏洞
    我还有一种感觉,代码中的漏洞比可用的逻辑更多。

  • 没有优雅的关闭
    没有经过协商的客户端‑服务器断开——只有直接的连接中断。

虽然这是我的第一个项目,但我觉得结果并没有太糟。如果有人想看看这堆烂摊子,链接如下:

  • Client:
  • Server:

实现目前可以工作,我正通过自己的 VPN 协议撰写本文。

未来计划

  • 将两个仓库合并为一个。
  • 添加伪造数据包发送。
  • 添加 TLS 模拟。
  • 以及更多。

如果有人有任何问题或建议,请在评论中留下。暂时就先说再见,祝大家好运!

0 浏览
Back to Blog

相关文章

阅读更多 »

5 分钟搞懂 VPN 与代理

基本概念:VPN 和代理都充当你与互联网之间的中间人。你的设备不是直接与网站通信,而是通过它们发送请求。

网络地址转换 (NAT)

互联网依赖数值IP地址在设备之间路由数据。IPv4提供约43亿个地址,这对于全球需求来说不足……