我构建 VPN 协议的故事:第1部分
抱歉,我需要您提供要翻译的具体文本内容(文章正文)。请把您想要翻译的文字粘贴在这里,我会按照要求保留源链接并进行翻译。
免责声明
本文及 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 字节 | 密文长度 – 用于将垃圾字节与密文分离。 |
在头部之后,数据包包含:
- 12 字节 – 随机生成的 nonce
- 密文
- AEAD(认证标签)
- 垃圾字节
握手与密钥交换
1. 来自客户端的第一个数据包
客户端向服务器发送其 16 字节的用户名(当然是加密的)。
2. 服务器响应
如果服务器找到具有该用户名的用户,它会:
- 向客户端发送一个随机生成的 32 字节盐值
- 开始计算密钥:
- 发送密钥(服务器 → 客户端)
- 接收密钥(服务器 ← 客户端)
3. 服务器上的密钥计算
服务器以明文形式存储用户的密码。
- 接收密钥(用于解密来自客户端的数据) =
hash(password + first 16 bytes of salt) - 发送密钥(用于加密发送给客户端的数据) =
hash(password + last 16 bytes of salt)
4. 客户端操作
客户端接收盐值,解密后执行相同的操作,但密钥角色相反:
- 对服务器而言的 发送密钥 成为客户端的 接收密钥,反之亦然。
5. ECDH 与连接完成
- 客户端生成密钥后,基于 Curve25519 椭圆曲线创建一个 临时密钥对(用于 ECDH)。
- 然后它发送一个 连接确认(
0xFF)以及其公用临时密钥,并设置 ECDH 标志。
服务器接收该数据包,去混淆后获取确认信息和客户端的临时密钥。随后它:
- 从本地私有网络为客户端分配一个 IP 地址
- 生成自己的临时密钥对
- 将分配的 IP 地址和服务器的公钥发送给客户端
- 执行 ECDH 轮次
发送完毕后,服务器通过将旧密钥与 ECDH 获得的密钥进行哈希来更新密钥。
6. 客户端完成
当客户端收到包含 IP 地址和服务器公用临时密钥的数据包时,它会:
- 创建本地隧道
- 设置其 IP 地址(即从服务器收到的地址)
- 执行 ECDH 轮次
- 更新密钥
主工作循环
在连接建立并生成密钥后,主工作循环开始。
客户端
三个 goroutine 在客户端运行:
-
从隧道读取并准备数据包
- 从隧道读取数据包。
- 生成一个 8 字节盐,通过将旧的发送密钥与盐哈希来更新发送密钥。
- 将该盐前置到明文前面(
盐 + 隧道数据包)。 - 对所有内容进行加密。
- 添加随机垃圾字节以进行混淆。
- 将准备好的数据包存入缓冲区。
-
发送数据包
- 发送已经准备好的数据包。
- 数据包以 1‑5 个为一批 发送(协议当然在 OSI 第 3 层和第 4 层被分段,但这超出我的控制范围)。
-
接收来自服务器的数据包
- 接收服务器发来的数据包。
- 执行去混淆和解密。
- 将解密后的数据写入隧道。
服务器
服务器拥有 三个主要 goroutine,外加用于接收来自客户端数据包的额外 goroutine。
- 握手处理 – 处理传入的握手请求。如果握手成功,会 生成一个新的 goroutine 来处理该客户端的数据包。
- 从隧道读取 – 从隧道读取数据包并转发给客户端。
- 清理不活跃的连接 – 移除陈旧的连接。
关键更新
每个数据包中的盐
每个数据包(无论是客户端还是服务器)都包含一个 盐 用于更新密钥:
- 服务器(发送): 包含盐,然后通过将旧密钥与该盐哈希来更新其 发送密钥。
- 客户端(接收): 解密数据包后,使用相同的盐更新其 接收密钥。
当客户端发送数据包时,角色互换(客户端更新其发送密钥,服务器更新其接收密钥)。
定期 ECDH 更新
每 4 分钟 或 发送 2³² 个数据包(以先到者为准)时,使用椭圆曲线的 ECDH 交换刷新密钥。新密钥与数据包一起传输。
实现
在实现过程中,我考虑使用 Go 或 Rust 编写。我选择 Go 因为它的简洁性。
协议描述结束。
On Process
说实话,这个协议架构大部分是在写代码的过程中才形成的。它存在不少问题——无论是协议设计还是实现方面。
示例问题
-
固定的用户名数据包长度
加密后的用户名数据包长度固定为44 字节(12 字节 nonce、16 字节密文和 16 字节 AEAD 标签)。只要知道这一点并且确认用户在使用该协议,就可以算出密钥的第 4、5 个字节。 -
仓库重复
我愚蠢地创建了两个独立的仓库——一个用于客户端,一个用于服务器。于是包含公共模块的分支相互重复。 -
Git flow
我尝试遵循 Git Flow,但同样失败了。 -
漏洞
我还有一种感觉,代码中的漏洞比可用的逻辑更多。 -
没有优雅的关闭
没有经过协商的客户端‑服务器断开——只有直接的连接中断。
虽然这是我的第一个项目,但我觉得结果并没有太糟。如果有人想看看这堆烂摊子,链接如下:
- Client:
- Server:
实现目前可以工作,我正通过自己的 VPN 协议撰写本文。
未来计划
- 将两个仓库合并为一个。
- 添加伪造数据包发送。
- 添加 TLS 模拟。
- 以及更多。
如果有人有任何问题或建议,请在评论中留下。暂时就先说再见,祝大家好运!