Files
EasyTier/easytier/docs/peer_conn_secure_mode_v3.md
T
KKRainbow 101f416268 Introduce secure mode (part 1) (#1808)
Use noise protocol on handshake. Check peer's public key if needed. Also support rekey and replay attack prevention.

E2EE and temporary password will be implemented based on this.
2026-01-25 20:16:51 +08:00

19 KiB
Raw Blame History

PeerConn Secure Mode(乱序隧道友好)

本文是对“PeerConn 安全模式”下一阶段协议的完整规格草案,目标是在底层 Tunnel 不保证顺序交付(可能乱序/丢包)的前提下:

  • 仍使用 Noise 进行握手(加密、认证、channel binding
  • 数据面不使用 snow::TransportState 逐包加解密(因为它隐式递增 nonce,要求有序)
  • 在数据包尾部携带 12B 明文 nonce(与项目当前的“包尾 nonce”加密格式对齐),并把 epoch 编进 nonce
  • 以尽可能低的内存开销实现 anti-replay(默认窗口 256
  • 多条 PeerConn 之间复用同一份 Peer 级安全会话(PeerSession

该文档只描述协议与数据结构;实现时以本文为准做迭代。


背景

节点角色

系统中常见两类角色(由配置与信任锚点决定,而非代码里硬编码的“节点类型”):

  • 用户节点(同网节点):通常持有 network_secret,期望与同一 network_name 的其他节点建立强认证连接。
  • 共享节点(基础设施节点):通常不持有用户的 network_secret,用于为多个用户网络提供转发/中继能力;客户端可通过 pinning 共享节点的长期 static 公钥获得“服务器认证”。

基于握手中交换的 network_name 可以得到 角色提示(role hint

  • a_network_name == b_network_name:同网提示
  • 否则:共享节点/外网提示

network_name 不是认证锚点;安全决策应基于 pinning 或 network_secret_confirmed(见 8)。

连接方式与 PeerConn

在实现中,“peer 与 peer 之间的连接”由 PeerConn 表示,它绑定一条底层 Tunnel(可能是 tcp/udp/quic/wg/ring 等),并以 PeerManagerHeader 承载上层消息:

  • PacketType::HandShake:传统 PeerConn 握手
  • PacketType::NoiseHandshake:安全模式下的 Noise 握手

参考:packet_def.rs

PeerManager 会在连接建立时走不同入口:

  • 主动方:add_client_tunnel -> PeerConn::do_handshake_as_client
  • 被动方:add_tunnel_as_server -> PeerConn::do_handshake_as_server

参考:peer_manager.rs

多连接与 foreign network

同一对 peer 之间可能出现多条 PeerConn(多路径、多协议、重连等),因此需要一个 Peer 级别的“安全会话”来复用认证结果与数据面密钥(见 7.3 的 PeerSession)。

此外,当握手得到的对端 network_name 与本地不一致时,PeerManager 会将该连接纳入 foreign network 相关逻辑(例如 foreign network client/manager)以支持“共享节点”模式与跨网络转发:

参考:peer_manager.rs

为什么需要本方案

若数据面直接使用 snow::TransportState 逐包加解密,会隐式递增 nonce,从而要求底层按序交付。由于本项目的数据加密格式本就采用“包尾明文 nonce”(例如 AesGcmTail.nonce[12],以及 ring ChaCha20-Poly1305 的同形 tail),因此本文延续“尾部 nonce”风格,并将 nonce 结构化为 epoch||seq,以满足:

  • 乱序可解密
  • 低内存 anti-replay
  • epoch/key 轮换
  • 多 PeerConn 复用 PeerSession

参考:packet_def.rs:AesGcmTailring_chacha20.rs


0. 约束与假设

  • 底层 tunnel 可能乱序/丢包;因此数据面必须支持乱序解密。
  • 外层已有 PeerManagerHeader,包含 from_peer_id / to_peer_id,可作为对端身份索引,数据面无需额外携带 session_id
  • 保持既有的安全语义目标:
    • 共享节点 pinning(基于对端 Noise static pubkey
    • network_secret 的 channel binding 确认(handshake_hash
    • “尽早交换 network_name”用于角色判断(同网/共享节点)

1. 术语

  • PeerConn:一条具体的底层连接/路径(可能同一对 peer 存在多条)。
  • PeerSession:Peer 级别的安全会话状态(密钥、epoch、nonce、anti-replay、认证等级等)。
  • epoch:数据面密钥版本号(key id)。编码进 12B nonce 的高 4B。
  • seq:发送序号(per-direction 单调递增 u64)。编码进 12B nonce 的低 8B。
  • nonce12:明文 12B nonce,按 epoch||seq 编码,附在密文尾部。
  • AAD:AEAD 的附加认证数据。本文建议使用空 AAD(与项目当前的 ring/openssl 加密实现一致),未来可扩展为覆盖部分 header。

2. 关键 wire 结构(参考)

2.1 PeerManagerHeader16B

来自 packet_def.rs

字段 类型 大小
from_peer_id u32 (LE) 4
to_peer_id u32 (LE) 4
packet_type u8 1
flags u8 1
forward_counter u8 1
reserved u8 1
len u32 (LE) 4
合计 16B

2.2 AES-GCM 包尾(28B

来自 packet_def.rs

AesGcmTail {
  tag:   [u8; 16]  // 16B
  nonce: [u8; 12]  // 12B
} // 合计 28B

ring ChaCha20-Poly1305 的尾部结构与之同形: ring_chacha20.rs


3. 数据面:nonce/epoch/seq 规格

3.1 Nonce1212B,明文附在包尾)

定义:

字段 编码 大小
epoch u32 big-endian 4
seq u64 big-endian 8
合计 12B

记为:

nonce12 = epoch_be_u32 || seq_be_u64

3.2 发送端规则(每方向)

  • seq:u64 单调递增,从 0 开始,每发送一个包 seq += 1
  • epochu32,初始为 0。轮换时 epoch += 1 并切换到新 key。
  • nonce12:按 epoch||seq 生成,作为 AEAD nonce,同时明文写入包尾。

安全性要求:同一把 data key 下,nonce12 必须不重复。该要求通过“epoch 变化必然对应 key 变化 + 同一 epoch 内 seq 单调递增”保证。

3.3 接收端规则(每方向)

通信是双向的:双方都会为每个对端 peer 维护一份 PeerSession。其中发送方向状态用于生成 nonce12(见 3.2),接收方向状态用于乱序解密与 anti-replay(见本节与第 5 节)。本节仅描述“接收路径”的处理流程:

  1. 从包尾读取 nonce12,解析出 (epoch, seq)
  2. 选择对应 epoch 的 data key(允许短期保留多个 epoch,见 6.2)。
  3. 执行 anti-replay 检查(见 5)。
  4. AEAD 解密 payloadtag 校验失败视为丢包)。

4. 数据面:AEAD 封装

4.1 选择算法

本文以“尾部 tag(16) + nonce(12)”为基准,兼容:

  • AES-256-GCMtag=16, nonce=12, key=32
  • ChaCha20-Poly1305tag=16, nonce=12, key=32

4.2 密文布局(以 AEAD tail 形式描述)

wire_payload = ciphertext || tag16 || nonce12

其中:

  • ciphertext:对原 payload 的加密结果(与原明文等长)
  • tag16AEAD tag16B
  • nonce12:明文(12B),用于乱序解密与 anti-replay

4.3 AAD

默认:AAD = empty(与项目当前 ring encryptor 一致)。

扩展(可选):未来可把 PeerManagerHeader 的部分字段纳入 AAD(例如 from/to/packet_type/flags),以抵御“改 header 不改密文”的攻击面。该扩展不影响 nonce/epoch/seq 设计。


5. anti-replay(最小内存配置)

5.1 默认窗口参数

  • window_size = 256
  • keep_epochs = 2current + previous
  • evict_idle_after = 30s(某 epoch 长时间无包则回收其窗口与 key)

5.2 ReplayWindow256(概念结构与大小)

按“尽可能低内存”为目标,建议使用固定大小窗口(bitmap):

ReplayWindow256 {
  max_seq: u64        // 8B
  bitmap: [u8; 32]    // 256bit = 32B
} // 合计 40B(按字段大小计,不含语言实现的对齐/额外元数据)

说明:

  • bitmap 的第 0 位表示 max_seq 是否已见;第 i 位表示 max_seq - i 是否已见。
  • seq > max_seq:右移窗口并置位。
  • seq <= max_seq:计算 delta = max_seq - seq,若 delta >= 256 丢弃(视为太旧);否则检查 bitmap 是否已置位,已置位则丢弃(重放),未置位则接受并置位。

5.3 ReplayState(每个对端、每个方向、每个 epoch)

为减少内存,可用“固定 2 个 epoch 槽位”而非 HashMap

EpochRxSlot {
  epoch: u32              // 4B
  window: ReplayWindow256 // 40B
  last_rx_ms: u64         // 8B(用于 30s 淘汰)
  valid: bool             // 1B(实现细节)
}

每个对端、每个方向保留 2 个 EpochRxSlot

  • current_epoch_slot
  • previous_epoch_slot

内存量级(按字段大小粗算):

  • 单方向:约 2 * (4 + 40 + 8 + 1) ≈ 106B
  • 双方向:约 212B

加上 epoch key 缓存(见 6.2)仍处于“每对端几百字节”级别。


6. epoch 与密钥派生/轮换

6.1 密钥层次

推荐将 Noise 仅用于握手与认证绑定,数据面 key 由一个会话根密钥 root_key 派生:

root_key: [u8; 32]  // 会话根密钥材料

随后对每个 epoch 与方向派生 traffic key

k(epoch, dir) = HKDF(root_key, "et-traffic" || epoch_u32_be || dir_byte)
  • dir_byte:发送方向标识(例如 0=tx, 1=rx)
  • 输出长度:32B(用于 AES-256-GCM 或 ChaCha20-Poly1305

6.2 key 缓存(keep_epochs = 2

对每个对端 peer、每个方向,缓存 2 个 epoch 的 key

EpochKeySlot {
  epoch: u32
  key: [u8; 32]   // 32B
  valid: bool
}

接收时按 (epoch) 选择 key;若 epoch 是 current 或 previous 则可解密,否则丢弃(或可选:尝试少量临近 epoch,代价是试解密)。

6.3 轮换策略(默认无额外控制消息)

为减少协议复杂度,默认策略:

  • 发送端在满足“包数阈值”或“时间阈值”时将 epoch += 1 并开始使用新 key。
  • 接收端不需要提前知道轮换点:从明文 nonce12.epoch 即可选择正确 key。
  • 接收端保留 keep_epochs=2,保证轮换期间乱序旧包仍可解密。

可选增强(未来):

  • 若希望更强一致性,可定义一个控制包通告 epoch_advance,但不是本方案的必要条件。

7. 握手层:Noise_XX + 角色/认证/会话根密钥

7.1 目标

在每条 PeerConn 建立时运行 Noise_XX 握手,完成:

  • 交换 network_name(尽早判断同网/共享节点)
  • 完成共享节点 pubkey pinning(若配置)
  • 完成 network_secret_confirmed(若双方都有 secret
  • 协商 PeerSessionjoin 或 create),并在需要时同步 root_keyepoch 起点

7.2 prologue

prologue 固定为协议版本字符串,不包含 network_name,以避免跨 network_name 的连接被拒绝:

prologue = "easytier-peerconn-noise"

7.3 PeerSessionjoin / create / sync 规则

本文引入 Peer 级会话 PeerSession(每个对端 peer 一份),用于跨多条 PeerConn 复用数据面密钥与 anti-replay 状态。

7.3.1 PeerSession 的身份字段

数据面不携带 session_id,因此会话的“索引键”是外层 PeerManagerHeader.from_peer_id(对端 peer_id)。但为了在握手阶段判断 join/create/sync,需要额外维护:

PeerSessionMeta {
  session_generation: u32  // 4B,单调递增,会话根密钥 root_key 的版本号
  auth_level: u8           // 1B,对齐 secure_auth_level 的枚举语义
}

语义:

  • session_generation 变化表示 root_key 发生轮换(create)。
  • session_generation 不变表示复用已有 root_keyjoin)。

7.3.2 参与方角色

  • Initiator:发起连接的一方(A)
  • Responder:接收连接的一方(B)

在本方案中,Responder 对会话选择具有权威性:最终使用哪一代 root_key 以 msg2 返回为准。

7.3.3 Responder 的决策(核心)

Responder 在收到 msg1 后,读取 Initiator 提供的 a_session_generation(可选)并与本地 PeerSession 进行对比,按以下优先级决策:

  1. 本地不存在 PeerSession:执行 CREATE(生成新的 root_keysession_generation=1)。
  2. 本地存在 PeerSession 且 a_session_generation 与本地一致:执行 JOIN(不轮换 root_key)。
  3. 本地存在 PeerSession 但 a_session_generation 缺失或不一致:执行 SYNC(不轮换 root_key,但在 msg2 中携带当前 root_keysession_generation,使对端同步到本地会话)。

安全性与 DoS 说明:

  • 默认不允许对端通过握手触发 root_key 轮换(避免对端反复拨号导致会话重置)。
  • 只有在“本地不存在会话”或“本地策略显式要求轮换”(例如人工触发、密钥泄露处置)时才执行 CREATE

7.3.4 Initiator 的行为

Initiator 在握手开始前读取本地是否已有对端 PeerSession

  • 若存在:在 msg1 中携带本地 a_session_generation
  • 若不存在:msg1 不携带 a_session_generation

Initiator 在收到 msg2 后:

  • 若 msg2 为 JOIN:继续使用本地 root_keysession_generation(不重置 epoch/seq)。
  • 若 msg2 为 SYNC / CREATE 且携带 root_key:用 msg2 携带的 root_key 覆盖本地会话,并将数据面计数器重置为 initial_epochseq=0,重放窗口清空。

7.4 握手 payload 编码:protobuf vs 固定布局

推荐使用 protobufpb)来编码 Noise 握手消息的 payload,原因:

  • 易演进(字段可选、可扩展、兼容旧版本)
  • 项目内已广泛使用 pb(例如 HandshakeRequest
  • 开销可控:除去字符串外,核心字段均为固定长度 bytes(16/32/12),pb 仅增加少量 tag/len varint

可选方案:固定布局。若你追求极致性能/可预测大小,可将同等字段按固定布局编码。本文以下默认以 protobuf 形式定义字段;固定布局可按同样字段直接平铺实现。

7.5 握手消息(Noise_XX 的 3 条消息)

记:

  • msg1: A -> Bpayload 明文)
  • msg2: B -> Apayload 加密)
  • msg3: A -> Bpayload 加密)

7.5.1 pb 定义(字段类型与语义大小)

下述为“协议级定义”(概念 proto),不要求立刻落入代码生成;实现可在 proto 中新增 message,或先在 Rust 侧用 prost 定义本地 message。

message PeerConnNoiseMsg1Pb {
  uint32 version = 1;                 // varint
  string a_network_name = 2;          // len <= 64 bytes (建议约束)
  optional uint32 a_session_generation = 3; // varint,可选
  bytes a_conn_id = 4;                // 16B (UUID)
}

enum PeerConnSessionActionPb {
  JOIN = 0;   // 不发送 root_key,表示“继续使用既有会话”
  SYNC = 1;   // 发送 root_key,用于对端同步到本地会话
  CREATE = 2; // 发送 root_key,表示本地新建会话
}

message PeerConnNoiseMsg2Pb {
  string b_network_name = 1;          // len <= 64 bytes
  uint32 role_hint = 2;               // 1=同网提示, 2=共享节点/外网提示

  PeerConnSessionActionPb action = 3; // JOIN/SYNC/CREATE
  uint32 b_session_generation = 4;    // varint

  optional bytes root_key_32 = 5;     // 32B,当 action=SYNC/CREATE 时必须存在
  uint32 initial_epoch = 6;           // u32(编码为 varint 或 fixed32 均可),建议语义为 BE u32 值

  bytes b_conn_id = 7;                // 16B (UUID)
  bytes a_conn_id_echo = 8;           // 16B (UUID)
}

message PeerConnNoiseMsg3Pb {
  bytes a_conn_id_echo = 1;           // 16B
  bytes b_conn_id_echo = 2;           // 16B

  // 可选:network_secret_confirmed 的 proof
  optional bytes secret_proof_32 = 3; // 32B
}

字段语义大小(不含 pb tag/len):

  • UUID16B
  • root_key32B
  • secret_proof32B
  • initial_epoch4B(逻辑大小;pb 编码本身为 varint/fixed32wire 大小可变或 4B

7.5.2 msg1 payloadA -> B,明文)

payload_bytes = PeerConnNoiseMsg1Pb.encode_to_vec()

说明:

  • 该 payload 为明文,因此不放 root_key 等敏感材料。
  • a_network_name 用于角色提示。
  • a_session_generation 用于 Responder 做 join/sync/create 决策。
  • a_conn_id 用于本次连接绑定(防拼接),将在 msg2/msg3 回显。

7.5.3 msg2 payloadB -> A,加密)

payload_bytes = PeerConnNoiseMsg2Pb.encode_to_vec()

说明:

  • action 决定本次握手是否会更新会话根密钥:
    • JOIN:不发送 root_key_32,表示“继续使用既有会话”
    • SYNC:发送 root_key_32,用于对端同步到本地既有会话
    • CREATE:发送 root_key_32,表示本地创建新会话
  • initial_epoch 默认 0;若需要随机化,可设置为随机 u32,但需要接收端 key/窗口缓存支持更复杂的淘汰策略。
  • a_conn_id_echob_conn_id 用于连接绑定;msg3 将回显两者以确认双方看到同一组值。

7.5.4 msg3 payloadA -> B,加密)

payload_bytes = PeerConnNoiseMsg3Pb.encode_to_vec()

secret_proof_32(可选)用于 network_secret_confirmed

secret_proof = HMAC-SHA256(
  key = derive(network_secret),
  data = role_byte || handshake_hash
)

其中 handshake_hash 由 Noise 提供,role_byte 用于区分双方角色(client/server)。

7.6 pinning(共享节点)

  • 配置位置:PeerConfig.peer_public_keybase6432B)。
  • 校验时机:Noise 握手结束后,A 读取 remote_static_pubkey,若配置了 pinned 则必须匹配,否则断连。

8. 角色判断与安全语义

  • network_name 的比较足以用于 角色提示
    • a_network_name == b_network_name:同网提示
    • 否则:共享节点/外网提示
  • network_name 不是认证锚点。安全决策仅应基于:
    • 共享节点 pinning 成功(shared_node_pubkey_verified
    • 或 network_secret_confirmed 成功(network_secret_confirmed
  • 在未完成上述任一认证前,连接为 encrypted_unauthenticated:仅保证机密性/完整性,不保证对端身份,存在 MITM 风险。

9. 与包尾 nonce 加密格式的关系

项目当前的 ring chacha20 加密实现使用随机 nonce 并将 nonce 明文附在包尾: ring_chacha20.rs

本文将随机 nonce 替换为结构化 epoch||seq

  • 仍为 12B
  • 仍明文放包尾
  • 但语义从“随机唯一”变为“可乱序解密 + 可 anti-replay + 可轮换”

10. 默认参数汇总

  • nonce12B = epoch(u32 BE) + seq(u64 BE)
  • tag16B
  • key32BAES-256-GCM 或 ChaCha20-Poly1305
  • replay window256bitmap 32B
  • keep_epochs2current + previous
  • evict_idle_after30s