包加密:ChaCha20-Poly1305 与确定性证书
专栏:VNT 2.0 组网源码 · 第 5 / 11 篇:::info 学习目标 完成本篇后你能够:解释”服务端零知识”的实现机制;画出 16 字节包头与 nonce/AAD 的对应关系;说出确定性证书方案解决的问题与代价。 前置:第 3 篇完成。预计时长:60 分钟。 :::
:::note 本章术语速查(新手建议先读)
- 对称加密:加密解密用同一把密钥(VNT 用 ChaCha20-Poly1305:快、移动端友好、防篡改)。
- Nonce:“用一次的数字”——同一密钥下 nonce 绝不重复,否则密码学失效。
- AAD:不加密但参与“防篡改校验”的数据。
- AEAD:加密+认证一体的算法族。
- Ed25519:现代签名算法,签名过程是确定性的(同样输入永远同样签名)——“由密码生成证书”靠这一点。
- 中间人攻击(MITM):攻击者冒充对方截获通信,VNT 用证书指纹防御。
- SHA256:哈希函数:任意输入 → 32 字节“指纹”,不可逆。 :::
VNT 的安全承诺:设置组网密码后,服务端只转发密文,无法解密通信内容。实现它没有用传统的”DH 密钥协商”,而是一套更简单的”密码即密钥”体系——代价与收益都值得细品。
包格式:一切加密设计的前提
// vnt-core/src/protocol/ip_packet_protocol.rs:1-15
/*
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 1 | msg_type(7) |max_ttl(4)|curr_ttl(4)| C | G | F | E | reserve(12) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| seq(32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| src ID(32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| dest ID(32) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| payload(n) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
*/
16 字节头:msg_type(Turn/Ping/PunchReq…)、双重 TTL、四个标志位(C 压缩/G 网关/F FEC/E 以太网)、32 位 seq、源/目的虚拟 IP。中继转发会修改 ttl——这个事实决定了下面的 AAD 设计。
包加密:nonce 与 AAD 的分工
// vnt-core/src/crypto/chacha20_poly1305.rs:51-73(节选)
pub fn make_nonce<B: AsRef<[u8]>>(&self, pkt: &NetPacket<B>) -> io::Result<[u8; 12]> {
let buf = pkt.buffer();
let seq = &buf[4..8];
let src = &buf[8..12];
let dst = &buf[12..16];
let mut nonce12 = [0u8; 12];
nonce12[0..4].copy_from_slice(seq); // 递增计数器(随机起始)
nonce12[4..8].copy_from_slice(dst); // 目的 IP
nonce12[8..12].copy_from_slice(src); // 源 IP
Ok(nonce12)
}
ChaCha20-Poly1305 要求同一密钥下 nonce 绝不重复。VNT 的 nonce = seq(32 位递增,随机起始防重启后低段重放) + 源 IP + 目的 IP——三者构成每个流内不重复的 96 位值(约 43 亿包后回绕)。
**AAD(附加认证数据)**只覆盖包头 byte0/2/3(msg_type、flags、reserved)——这些字段传输中不变,必须认证防篡改;byte1(ttl)被明确排除,因为中继转发要递减它。源码里甚至有两个专门的回归测试:test_ttl_change_still_decrypts(TTL 改变仍可解密)与反篡改测试(改 flags 必须解密失败)。字段”是否可变”被精确编码进认证范围——这是 AEAD 工程应用的教科书案例。
加密是原地的:tag(16 字节)写入 payload 尾部预留空间,密钥 = SHA256(组网密码)(无密码则完全不加密)。
控制面:没有密钥协商,怎么防中间人?
VNT 的答案分两层:
第一层:口令指纹(key_sign)。注册时客户端上送 SHA256("KEY-BEGIN"+密码+"KEY-END") 前 16 字节的 hex——服务端用它校验”同一组网编号必须同密码”(服务端零知识:它只知道指纹)。
第二层:确定性证书。QUIC/TLS 的证书不是随机生成的,而是由密码确定性派生:
// vnt-core/src/tls/cert.rs:17-54(节选)
pub fn generate_deterministic_cert(password: &str) -> Result<...> {
let seed = derive_seed_from_password(password); // 对 "vnt-ed25519-seed-v1:"+pwd 迭代 SHA256 ×10
... // Ed25519(RFC 8032 确定性签名)
params.not_after = not_before + Duration::days(365 * 1000); // 有效期 1000 年
let cert = params.self_signed(&key_pair)?;
同一密码 → 同一密钥对 → 同一证书,所有节点天然互信(用指纹比对 SHA256(cert) + 真实 TLS13 签名验证——源码注释明示这里修复过”只比指纹不验签”的 MITM 缺陷)。好处:零配置、离线可组网、无密钥分发基础设施;代价:密码强度即安全强度(熵由密码决定),且证书永不轮换。三档校验模式 skip(默认,仅同密码簇内互信)/ finger:<sha256>(钉死证书)/ standard(系统 CA,用于自建受信服务端)。
加密在链路上的位置
顺序有讲究:先压缩后加密(密文不可压)、FEC 在加密前(冗余包也要被加密)。唯一例外是 QUIC 通道(MsgType=Quic 不叠加包加密——TLS1.3 已保护,第 8 篇)。
常见踩坑
- 不设密码——完全明文且任何知道编号的人都能加入;README 明确建议设密码;
- 弱密码——确定性方案下密码熵=安全上限,123456 等于裸奔;
- 以为服务端”参与”加密——它只是密文搬运工(GATEWAY 标志的虚拟网关 ping 应答除外,且仅限 ICMP EchoReply)。
随堂练习(带验收标准)
- 抓物理网卡包(tcpdump),确认内容是密文;换一个密码后旧密文无法解密;
- 画 nonce 与 16 字节包头的字段对应图,并解释 TTL 为何必须排除在 AAD 外;
- 进阶:读
crypto/chacha20_poly1305.rs的两个回归测试,总结”AEAD 用于协议设计”的三条教训。