跳转到内容

VMess:密钥与认证

源码文件:20 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/vmess/aead.rs
  • Etemenanki/protocols/src/vmess/keys.rs
  • Etemenanki/protocols/src/vmess/accounts.rs
  • Etemenanki/protocols/src/macros.rs
  • Etemenanki/protocols/src/vmess/protocol.rs
  • Etemenanki/protocols/src/vmess/session.rs
  • Etemenanki/protocols/src/vmess/framing.rs
  • Etemenanki/protocols/src/vmess/core.rs
  • Etemenanki/protocols/src/error.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/protocols/tests/unit/vmess/aead.rs
  • Etemenanki/protocols/tests/unit/vmess/accounts.rs
  • Etemenanki/protocols/tests/unit/vmess/protocol.rs
  • Etemenanki/protocols/tests/unit/vmess/core.rs
  • Etemenanki/protocols/tests/unit/vmess/codec.rs
  • Etemenanki/protocols/tests/pipeline/vmess.rs
  • Etemenanki/app/tests/integration/e2e_xray_vmess.rs
  • katana/src/inbound.rs
  • katana/src/serve.rs

Etemenanki 中的 VMess 是仅支持 AEAD 的变体:每条连接都以一个 16 字节的认证 ID(auth id)开头,随后是用 AES-128-GCM 封装的请求头。本页讲的是该协议的密码学部分:每个密钥如何从用户 UUID 派生,认证 ID 如何构造与校验,请求头封套如何封装与打开,crate 内自带的 SHAKE128,带类型的密钥包装类型,以及 AccountValidator——它把收到的 ID 与所有已配置用户逐一比对,并拒绝重放。

内层请求头的字节布局、请求体的分块帧格式以及服务端状态机见 VMess:线格式与协议核心。修改密钥派生、盐值、时间窗口或重放集合之前,请先读完本页。派生方式和盐值必须与 Xray 保持字节级兼容。所有单元测试都用本 crate 自己的代码封装和打开,因此只有 Xray 互操作测试能发现盐值或 KDF 步骤写错,而这些测试在未安装 go 时会被跳过。

文件 符号 职责
protocols/src/vmess/aead.rs kdf, kdf16, cmd_key 递归 HMAC-SHA256 KDF 以及账户的 cmd key。
protocols/src/vmess/aead.rs GcmKey, GcmNonce, ConnNonce 为每个请求头密钥和 nonce 提供按用途命名的构造函数。
protocols/src/vmess/aead.rs create_auth_id, auth_id_decipher, decode_auth_id_dec 16 字节认证 ID 的加密与解密。
protocols/src/vmess/aead.rs seal_vmess_aead_header, open_vmess_aead_header, open_vmess_aead_header_slice 封装后的请求头封套。
protocols/src/vmess/aead.rs Shake128 自包含的 SHAKE128 XOF,用于分块长度掩码和填充。
protocols/src/vmess/keys.rs CmdKey, BodyKey, BodyIv, AuthId, AuthIdPlain 带类型的 16 字节密钥材料,让编译器拒绝互相调换的密钥。
protocols/src/vmess/accounts.rs AccountValidator, Account, MatchedAccount 用户匹配、时间窗口、重放保护和扫描顺序调优。

该模块不解析内层请求头,不处理请求体帧,也不驱动连接。这些由 protocols/src/vmess/protocol.rs 和 protocols/src/vmess/framing.rs 负责,它们调用本模块;protocols/src/vmess/core.rs → VMessCore 在握手状态中调用 AccountValidator::authenticate 和 open_vmess_aead_header_slice。

每个用户的所有密钥都从账户的 cmd key 出发。cmd key 是 16 字节原始 UUID 后接一个固定魔数字符串的 MD5:

const CMD_KEY_MAGIC: &[u8] = b"c48619fe-8f02-49e0-b9e9-edf763e17e21";
pub fn cmd_key(uuid: &uuid::Uuid) -> CmdKey

CmdKey = MD5(uuid.as_bytes() || CMD_KEY_MAGIC)。其他派生都不读取 UUID;验证器保留 UUID 只是为了报告匹配到的是哪个账户。

kdf 是 VMess 的递归嵌套 HMAC。盐值 SALT_VMESS_AEAD_KDF("VMess AEAD KDF")作为最内层 HMAC-SHA256 的密钥,path 中每个元素再加一层 HMAC,其哈希函数就是下一层:

pub fn kdf(key: &[u8], path: &[&[u8]]) -> [u8; 32]
pub fn kdf16(key: &[u8], path: &[&[u8]]) -> [u8; 16]
fn hmac_chain(keys: &[&[u8]], level: usize, msg: &[u8]) -> [u8; 32]

对路径 p1 … pn:

层级 密钥 HMAC 内部的哈希函数
H0 "VMess AEAD KDF" SHA-256
H1 p1 H0
Hi pi H(i-1)
Hn pn H(n-1)

kdf(key, path) 返回完整 32 字节的 Hn(key),kdf16 截取其前 16 字节。hmac_chain 把每一层都当作分组 64 字节、输出 32 字节的哈希:长于 64 字节的密钥会先用下一层哈希一遍(第 0 层为 SHA-256)。输入它的盐值、认证 ID 和 nonce 都不超过 64 字节,因此这个分支只是为了正确性而存在。

flowchart LR
  K["输入密钥(cmd key 或 body key/IV)"] --> Hn
  subgraph chain["kdf(key, p1..pn)"]
    Hn["Hn:以 pn 为密钥的 HMAC"] -->|"内层与外层哈希"| H1["H1:以 p1 为密钥的 HMAC"]
    H1 -->|"内层与外层哈希"| H0["H0:以 'VMess AEAD KDF' 为密钥的 HMAC-SHA256"]
  end
  Hn --> Out["32 字节摘要"]
  Out --> K16["kdf16:前 16 字节(密钥)"]
  Out --> N12["前 12 字节(GCM nonce)"]

每一层都会调用下一层两次(内层和外层哈希),因此长度为 n 的路径要做 2^(n+1) 次 SHA-256。认证 ID 密钥(n = 1)需要 4 次。每个请求头密钥或 nonce(n = 3)需要 16 次,所以打开一个请求头要派生四个值,共 64 次 SHA-256 调用。这就是 AccountValidator 在构建时为每个用户派生一次认证 ID 密码器、而不是每条连接都派生的原因。

所有盐值都是 protocols/src/vmess/aead.rs 中的私有 &[u8] 常量,照抄自 Xray 的 proxy/vmess/aead/consts.go:

常量 值 派生对象
SALT_VMESS_AEAD_KDF VMess AEAD KDF 每次 kdf 调用最内层 HMAC 的密钥。
SALT_AUTHID_ENC_KEY AES Auth ID Encryption 认证 ID 使用的 AES-128 密钥,由 cmd key 派生。
SALT_HEADER_PAYLOAD_LEN_AEAD_KEY VMess Header AEAD Key_Length GcmKey::request_len
SALT_HEADER_PAYLOAD_LEN_AEAD_IV VMess Header AEAD Nonce_Length GcmNonce::request_len
SALT_HEADER_PAYLOAD_AEAD_KEY VMess Header AEAD Key GcmKey::request_payload
SALT_HEADER_PAYLOAD_AEAD_IV VMess Header AEAD Nonce GcmNonce::request_payload
SALT_AEAD_RESP_HEADER_LEN_KEY AEAD Resp Header Len Key GcmKey::response_len
SALT_AEAD_RESP_HEADER_LEN_IV AEAD Resp Header Len IV GcmNonce::response_len
SALT_AEAD_RESP_HEADER_PAYLOAD_KEY AEAD Resp Header Key GcmKey::response_payload
SALT_AEAD_RESP_HEADER_PAYLOAD_IV AEAD Resp Header IV GcmNonce::response_payload
flowchart TB
  uuid["用户 UUID"] -->|"与 CMD_KEY_MAGIC 一起做 MD5"| cmd["CmdKey"]
  cmd -->|"kdf16:AES Auth ID Encryption"| aid["认证 ID 的 AES-128 密钥"]
  cmd --> req["请求头:GcmKey 和 GcmNonce request_len、request_payload"]
  authid["AuthId(线上传输)"] --> req
  conn["ConnNonce(线上传输)"] --> req
  bk["BodyKey(随机,位于请求头内)"] -->|"SHA-256,取前 16 字节"| rbk["响应 BodyKey"]
  biv["BodyIv(随机,位于请求头内)"] -->|"SHA-256,取前 16 字节"| rbiv["响应 BodyIv"]
  rbk --> resp["响应头:GcmKey response_len、response_payload"]
  rbiv --> respn["响应头:GcmNonce response_len、response_payload"]
  biv --> shake["请求方向 Shake128"]
  rbiv --> rshake["响应方向 Shake128"]

请求头的密钥取决于 cmd key 以及两个明文传输的值:认证 ID 和连接 nonce。body key 和 IV 由客户端随机选取(protocols/src/vmess/session.rs → OutboundSession::new 中的 BodyKey::random、BodyIv::random),放在封装后的请求头中传递。两端都通过 protocols/src/vmess/protocol.rs → RequestSession::response 由它们派生出响应方向的值,该函数调用 BodyKey::response 和 BodyIv::response:客户端在构建会话时派生,服务端在打开请求头之后派生。响应头的密钥和 nonce 由这些响应方向的值派生。body key 如何变成 AES-128-GCM 或 ChaCha20-Poly1305 分块密码器,见 VMess:线格式与协议核心。

认证 ID 是每条 VMess 连接的前 16 字节:一个 AES-128-ECB 分组,用每用户密钥 kdf16(cmd_key, [SALT_AUTHID_ENC_KEY]) 加密。

偏移 长度 字段 含义
0 8 time 自 Unix 纪元起的秒数,有符号,大端序(i64::to_be_bytes)。
8 4 random 4 个随机字节,使同一秒内生成的两个 ID 不同。
12 4 CRC32 第 0 到 11 字节的 crc32fast::hash(IEEE CRC-32),大端序。
pub fn now_unix() -> i64
pub fn auth_id_cipher(cmd_key: &CmdKey) -> Aes128
pub fn create_auth_id_with_cipher(cipher: &Aes128, time: i64) -> AuthId
pub fn create_auth_id(cmd_key: &CmdKey, time: i64) -> AuthId
pub fn auth_id_decipher(cmd_key: &CmdKey) -> Aes128Dec
pub fn decode_auth_id_dec(cipher: &Aes128Dec, authid: &AuthId) -> AuthIdPlain

create_auth_id_with_cipher 填充分组,对前 12 字节计算 CRC,然后原地加密。客户端通过 seal_vmess_aead_header 调用 create_auth_id(cmd_key, now_unix())。如果系统时钟早于纪元,now_unix 返回 0。

服务端使用仅解密的 Aes128Dec:它只保存解密轮密钥,大小是完整 Aes128 的一半;每个用户都常驻一份时,这一点就很重要。decode_auth_id_dec 解密一个分组并返回 AuthIdPlain。

protocols/src/vmess/accounts.rs → decipher_matches 是扫描时对每个用户执行的检查:

  1. 用该用户的 Aes128Dec 解密分组。
  2. AuthIdPlain::crc_ok():存储的 CRC32 必须等于前 12 字节的 CRC32。用错误用户的密钥解密只会得到噪声,它通过此检查的概率为 2^-32。能通过这一步,就确定了 ID 所属的账户。
  3. AuthIdPlain::timestamp() 不能为负。
  4. now.checked_sub(t) 不能溢出,且其绝对值不超过 AUTHID_WINDOW_SECS(120)。边界是闭区间且对称,因此客户端时钟快或慢 120 秒以内都能被接受。

now 是服务端以秒为单位的墙上时钟。VMessCore::new 通过参数 now: fn() -> i64 接收它,核心在前 16 字节到达时调用 (self.now)()。app(app/src/serve.rs)、katana(src/serve.rs)以及当前所有测试传入的都是 aead::now_unix。

seal_vmess_aead_header 把内层请求头(由 protocols/src/vmess/protocol.rs 中的 encode_request_header 构造)包进如下封套:

字段 长度 含义
认证 ID 16 create_auth_id(cmd_key, now_unix()),明文发送。
封装后的长度 18 内层请求头长度,大端序 u16,用 AES-128-GCM 封装:2 字节密文加 16 字节 tag。
连接 nonce 8 ConnNonce::random(),明文发送。
封装后的载荷 L + 16 用 AES-128-GCM 封装的内层请求头(L 字节)加 tag。

两次封装都以 16 字节认证 ID 作为关联数据,因此长度和载荷都与随之到达的认证 ID 绑定。

每个密钥都是 kdf16,每个 nonce 都是完整 kdf 摘要的前 12 字节(GcmNonce::from_kdf)。

部分 密钥 nonce AAD
请求长度 kdf16(cmd_key, [Key_Length salt, auth_id, conn_nonce]) kdf(cmd_key, [Nonce_Length salt, auth_id, conn_nonce])[..12] 认证 ID
请求载荷 kdf16(cmd_key, [Key salt, auth_id, conn_nonce]) kdf(cmd_key, [Nonce salt, auth_id, conn_nonce])[..12] 认证 ID
响应长度 kdf16(response BodyKey, [Resp Header Len Key salt]) kdf(response BodyIv, [Resp Header Len IV salt])[..12] 空
响应载荷 kdf16(response BodyKey, [Resp Header Key salt]) kdf(response BodyIv, [Resp Header IV salt])[..12] 空

表中的盐值名是 盐值 一节所列常量的缩写。每对派生出的密钥和 nonce 恰好只封装一条消息:请求这一对随每个认证 ID 和连接 nonce 变化,响应这一对随每个随机 body key 和 IV 变化。

请求头相关类型用 protocols/src/macros.rs 中的 byte_newtype! 声明,它生成一个带私有字段的 Copy 元组结构体,以及 const fn as_bytes(&self) -> &[u8; N]:

pub struct GcmKey([u8; 16]);
pub struct GcmNonce([u8; 12]);
pub struct ConnNonce([u8; 8]);
impl GcmKey {
pub fn request_len(cmd_key: &CmdKey, auth_id: &AuthId, conn_nonce: &ConnNonce) -> Self
pub fn request_payload(cmd_key: &CmdKey, auth_id: &AuthId, conn_nonce: &ConnNonce) -> Self
pub fn response_len(body_key: &BodyKey) -> Self
pub fn response_payload(body_key: &BodyKey) -> Self
}
impl GcmNonce {
fn from_kdf(digest: [u8; 32]) -> Self
pub fn request_len(cmd_key: &CmdKey, auth_id: &AuthId, conn_nonce: &ConnNonce) -> Self
pub fn request_payload(cmd_key: &CmdKey, auth_id: &AuthId, conn_nonce: &ConnNonce) -> Self
pub fn response_len(body_iv: &BodyIv) -> Self
pub fn response_payload(body_iv: &BodyIv) -> Self
}
impl ConnNonce {
pub fn random() -> Self
pub const fn from_bytes(raw: [u8; 8]) -> Self
}

GcmKey 和 GcmNonce 没有公开的字节构造函数。在 aead.rs 之外,获得它们的唯一途径是按用途命名的构造函数,所以任何调用点都不会手工挑选盐值。长度密钥和载荷密钥类型相同、大小相同;把它们区分开的是构造函数,而不是类型。

pub const TAG_SIZE: usize = 16;
pub fn gcm_seal(key: &GcmKey, nonce: &GcmNonce, aad: &[u8], plaintext: &[u8]) -> Vec<u8>
pub fn gcm_open(key: &GcmKey, nonce: &GcmNonce, aad: &[u8], ciphertext: &[u8]) -> io::Result<Vec<u8>>
pub fn seal_vmess_aead_header(cmd_key: &CmdKey, data: &[u8]) -> Vec<u8>
pub async fn open_vmess_aead_header<R: AsyncRead + Unpin>(
cmd_key: &CmdKey,
authid: &AuthId,
reader: &mut R,
) -> io::Result<Vec<u8>>
pub fn open_vmess_aead_header_slice(
cmd_key: &CmdKey,
authid: &AuthId,
buf: &[u8],
) -> io::Result<Option<(Vec<u8>, usize)>>

两个打开函数都要求输入正好位于 16 字节认证 ID 之后,认证 ID 已由调用方读取并认证。它们读取 18 字节的封装长度和 8 字节的连接 nonce,先打开长度,再读取并打开 length + TAG_SIZE 字节的载荷。长度通过认证之前,载荷不会被触碰。

open_vmess_aead_header_slice 是 VMessCore 使用的 sans-I/O 形式。只要缓冲区还不够容纳长度部分、nonce 或完整载荷,它就返回 Ok(None)。一旦长度部分和 nonce 都已到齐,它立即打开长度,因此即使载荷尚未到达,错误的长度 tag 也会直接报错。整个请求头到齐后,它返回 Ok(Some((inner, used))),其中 used 统计认证 ID 之后用掉的字节数。随后核心把 used 作为已消费字节返回,并把请求体字节留在缓冲区中。基于 AsyncRead 的异步版本 open_vmess_aead_header 只在单元测试中使用。

响应头方向相反:protocols/src/vmess/protocol.rs → encode_response_header 用响应密钥和空 AAD 封装 4 字节载荷 [response_header, 0, 0, 0];客户端的 decode_response_header 和 decode_response_header_slice 打开它,并检查首字节是否回显了自己发送的 response_header 值。

sequenceDiagram
  participant C as 客户端
  participant Core as VMessCore
  participant V as AccountValidator
  participant A as aead
  C->>Core: 16 字节认证 ID
  Core->>V: authenticate(authid, now)
  V-->>Core: MatchedAccount (cmd_key, uuid, user_data)
  Note over Core: 状态 AuthId 转为 Header,消费 16 字节
  C->>Core: 封装后的长度、连接 nonce、封装后的载荷
  Core->>A: open_vmess_aead_header_slice(cmd_key, authid, buf)
  A-->>Core: Some(inner, used)
  Note over Core: parse_request_header、chunk_streams、response_header
  Core-->>C: 封装后的响应头(到需要回复时)

当 authenticate 返回 None 时,VMessCore 以 io::ErrorKind::PermissionDenied 和消息 vmess: unknown user or invalid auth id 让连接失败。未知用户、ID 超出时间窗口和重放都返回同一个错误,核心不加区分。

分块帧格式需要 SHAKE128 可扩展输出函数,而当前使用的 sha3 crate 版本已把它移到了单独的 crate。为了不增加依赖,aead.rs 直接实现了 FIPS 202:keccak_f1600(24 轮,使用 KECCAK_RC、KECCAK_RHO 和 KECCAK_PI 表)以及 rate 为 SHAKE128_RATE = 168 字节的海绵结构。

pub struct Shake128 {
state: [u64; 25],
pos: usize,
}
impl Shake128 {
pub fn new(seed: &[u8]) -> Self
pub fn read(&mut self, out: &mut [u8])
pub fn next_u16(&mut self) -> u16
pub fn next_padding_len(&mut self) -> u16
}
  • new 以 168 字节为块吸收种子,施加 SHAKE 的 pad10*1 填充(域分隔字节 0x1F,末位 0x80),并置换一次。此后实例只能挤出。
  • read 逐字节挤出,每当 pos 到达 168 时再置换一次。输出与读取如何切分无关;分块帧格式依赖这一点,因为它每次只取两个字节。
  • next_u16 取接下来的两个字节,大端序(对应 Xray 的 shakeSizeParser.next)。
  • next_padding_len 为 next_u16() % 64(对应 shakeSizeParser.NextPaddingLen)。

protocols/src/vmess/framing.rs → ChunkStream::new 为每个方向创建一个实例,以该方向的 16 字节 BodyIv 作为种子。每个分块先取填充长度(仅在协商了 global padding 时),再取 16 位长度掩码(仅在协商了 chunk masking 时)。顺序很重要:两端消费同一条密钥流,调换这两次读取会让两端从第一个分块起就失去同步。

VMess 要处理许多含义互不相关的 16 字节值。如果都用裸 [u8; 16],它们就可以互相替换,把密钥和 IV 调换了也能编译通过,直到对接真实对端时才失败。protocols/src/vmess/keys.rs 为每种含义单独定义一个类型。key_bytes! 宏声明结构体及其 from_bytes、as_bytes 和 into_bytes;secret_traits! 添加常数时间的 PartialEq(subtle::ConstantTimeEq)和打印 Name(<redacted>) 的 Debug。

pub struct CmdKey([u8; 16]);
pub struct BodyKey([u8; 16]);
pub struct BodyIv([u8; 16]);
pub struct AuthId([u8; 16]);
pub struct AuthIdPlain([u8; 16]);
// key_bytes! (CmdKey, BodyKey, BodyIv, AuthId)
pub const fn from_bytes(raw: [u8; 16]) -> Self
pub const fn as_bytes(&self) -> &[u8; 16]
pub const fn into_bytes(self) -> [u8; 16]
impl BodyKey {
pub fn random() -> Self
pub fn response(&self) -> Self
}
impl BodyIv {
pub fn random() -> Self
pub fn response(&self) -> Self
}
impl AuthId {
pub const fn shard_byte(&self) -> u8
}
impl AuthIdPlain {
pub const fn from_bytes(raw: [u8; 16]) -> Self
pub fn crc_ok(&self) -> bool
pub fn timestamp(&self) -> i64
}
类型 内容 相等比较 Debug 其他
CmdKey MD5(uuid, magic),账户的根密钥 常数时间 脱敏 无
BodyKey 每连接的 body key 常数时间 脱敏 random,response = SHA-256(key)[..16]
BodyIv 每连接的 body IV 常数时间 脱敏 random,response = SHA-256(iv)[..16]
AuthId 线上传输的认证 ID 密文 普通字节比较 十六进制 Hash(重放集合的键),shard_byte
AuthIdPlain 解密后的认证 ID 分组 无 仅 timestamp 和 crc_ok crc_ok,timestamp

请求到响应的派生放在 BodyKey::response 和 BodyIv::response 中,而不是一个自由函数里,所以在调用点再也无法把响应密钥和响应 IV 弄反。AuthIdPlain 是单独的类型,因此解密后的分组永远不能传到需要密文的地方。只存在于单个算法步骤内部的值,例如直接送入密码器的 KDF 摘要,仍保留为原始数组。

pub struct Account<T> {
pub uuid: Uuid,
pub cmd_key: CmdKey,
pub authid_cipher: Aes128,
pub user_data: Arc<T>,
}
impl<T> Account<T> {
pub fn from_uuid(uuid: &Uuid, user_data: Arc<T>) -> Self
}
pub struct MatchedAccount<T> {
pub cmd_key: CmdKey,
pub uuid: Uuid,
pub user_data: Arc<T>,
}
pub struct AccountValidator<T> {
snapshot: ArcSwap<Snapshot<T>>,
replay: [Mutex<ReplayShard>; REPLAY_SHARDS],
deep_hits: AtomicU64,
resort_lock: Mutex<()>,
}
impl<T> AccountValidator<T> {
pub fn new() -> Self
pub fn from_users(users: impl IntoIterator<Item = (Uuid, Arc<T>)>) -> Self
pub fn add(&self, uuid: Uuid, user_data: Arc<T>)
pub fn authenticate(&self, authid: &AuthId, now: i64) -> Option<MatchedAccount<T>>
fn maybe_resort(&self)
}

T 是路由器看到的每用户载荷:app 中为 (),katana 中为用户标签。认证 ID 无法建立索引,因为它的明文是用每用户密钥加密的 time || rand || crc,所以匹配只能对所有用户做线性的试解密。用户很多时,这次扫描是建立连接的主要开销,因此验证器的设计目标就是让它足够便宜:扫描本身不加锁,匹配路径上唯一的锁是一个重放分片,而且只在一次插入期间持有。

struct Snapshot<T> {
deciphers: Box<[Aes128Dec]>,
meta: Box<[UserMeta<T>]>,
hits: Box<[AtomicU64]>,
}
struct UserMeta<T> {
uuid: Uuid,
cmd_key: CmdKey,
user_data: Arc<T>,
}
impl<T> Snapshot<T> {
fn build(users: impl IntoIterator<Item = (Account<T>, u64)>) -> Self
fn scan(&self, authid: &AuthId, now: i64) -> Option<usize>
fn reordered_by_hits(&self) -> Self
}

Snapshot 一经发布就不可变。它的三个 boxed slice 按下标对齐,并按扫描顺序排列。新构建的快照保持传入用户的顺序(add 追加到末尾);重排之后,命中最多的用户排在最前:

  • deciphers 是扫描时每次探测都会读取的唯一数组。把仅解密的密钥调度连续存放,未命中时就能顺序读内存,而不是追着指针跳。
  • meta 只在匹配之后访问一次,用来复制出 cmd key、UUID 和载荷。
  • hits 记录每个用户的命中次数。authenticate 以 Ordering::Relaxed 递增它;只有重建时才读取。

scan 返回第一个通过 decipher_matches 的下标。

flowchart TB
  start["authenticate(authid, now)"] --> arrived["arrived = Instant::now()"]
  arrived --> load["snapshot.load()"]
  load --> scan{"scan:CRC 与时间窗口"}
  scan -->|"无匹配用户"| none1["None"]
  scan -->|"下标 idx"| shard["shard = shard_byte AND REPLAY_MASK"]
  shard --> admit{"shard.lock().admit(authid, arrived)"}
  admit -->|"已见过"| none2["None(重放)"]
  admit -->|"新 ID"| count["hits[idx] += 1"]
  count --> deep{"idx 不小于 1024 且 deep_hits 达到 64"}
  deep -->|"是"| resort["maybe_resort()"]
  deep -->|"否"| hit["Some(MatchedAccount)"]
  resort --> hit
  1. 在扫描之前,用单调时钟记录到达时间。
  2. 通过 ArcSwap::load 加载当前快照。这一步不加锁,并发的 add 或重排也无法改变本次调用扫描的快照。
  3. 扫描。没有匹配则返回 None。
  4. 根据认证 ID 的首字节选出重放分片,只锁住该分片执行一次 admit。重放则返回 None。
  5. 递增该用户的命中计数,复制出 MatchedAccount,并视情况触发重排。

扫描在重放检查之前进行,因此只有能用某个已配置用户的密钥解密、通过 CRC 且落在时间窗口内的 ID 才会进入重放集合。

#[derive(Default)]
struct ReplayShard {
seen: std::collections::HashSet<AuthId>,
order: std::collections::VecDeque<(Instant, AuthId)>,
}
impl ReplayShard {
fn admit(&mut self, authid: &AuthId, arrived: Instant) -> bool
}

重放集合被分成 REPLAY_SHARDS = 64 个分片,每个都是 parking_lot::Mutex<ReplayShard>。分片下标为 authid.shard_byte() as usize & REPLAY_MASK。认证 ID 是 AES 的输出,首字节均匀分布,无需再做哈希;REPLAY_SHARDS 必须保持为 2 的幂,掩码才能生效。

admit 先让 order 队首过期:只要最旧条目相对 arrived 的年龄超过 REPLAY_TTL,就把它从 order 和 seen 中同时移除。然后 admit 把 ID 插入 seen,若已存在则返回 false,否则把 (arrived, authid) 追加到 order。时间戳是单调的 Instant,所以墙上时钟跳变不会打乱队列,而从队首过期的均摊开销为 O(1)。过期是惰性的:只有在某个分片上执行 admit 时,该分片才会清理旧条目;每个通过扫描并映射到该分片的 ID 都会触发 admit,重放的 ID 也不例外。

REPLAY_TTL 为 2 * AUTHID_WINDOW_SECS = 240 秒。时间窗口是对称的,所以一个时间戳比当前快 120 秒的 ID 会一直通过时间检查,直到服务端时钟到达其时间戳再加 120 秒,也就是首次到达后 240 秒。TTL 再短,这样的 ID 就可能在条目过期后被重放。淘汰只按年龄、从不按数量,因此无论其他流量有多大,都无法挤掉验证器在 240 秒内接纳过的 ID。

在扫描前部命中很便宜;在列表深处命中则要为之前的每次探测付出代价。验证器会按命中次数重排快照,但只在热点集合明显偏离前缀时才这样做:

常量 值 作用
DEEP_HIT_DEPTH 1024 在 idx >= 1024 处的匹配算作深层命中。前缀内的命中永远不会进入重排路径。
DEEP_HITS_PER_RESORT 64 在尝试重排之前,deep_hits 中需累积的深层命中数。

当某次深层命中使 deep_hits 达到 64 或以上时,当前线程调用 maybe_resort:

  1. resort_lock.try_lock()。如果其他线程持有该锁,立即返回;扫描路径从不因重排而阻塞。
  2. 在锁内重新检查 deep_hits(其他线程可能刚完成重排),然后把它重置为 0。
  3. reordered_by_hits 按命中次数降序排列 (decipher, meta, hits) 三元组(使用 sorted_unstable_by_key,因此命中数相同者顺序不定),并克隆到新快照中。它克隆的是已展开的 Aes128Dec,而不是重新跑 KDF 和密钥扩展,所以一次重排只需对所有用户做一次排序和一次复制,不涉及任何密钥派生。
  4. 用 ArcSwap::store 发布新快照。

用户数不超过 1024 时,不会出现深层命中,顺序也就永远不变。命中计数只是参考值:在正被替换的快照上递增的计数,可能不会带入新快照。

构造函数 开销 命中计数 使用方
new() 空快照,64 个空分片。 无 Default,基于 add 的初始化
from_users(users) 一次 Snapshot::build。对每个用户:Account::from_uuid 计算 cmd_key(MD5)和完整的 auth_id_cipher(一次 KDF 加 AES 密钥扩展,不保存在快照中),然后 Snapshot::build 执行 auth_id_decipher(第二次 KDF 加解密密钥扩展)。 全为 0 app(app/src/inbound/mod.rs 中的 "vmess" 入站分支)和 katana(src/inbound.rs)
add(uuid, user_data) 在 resort_lock 下重建整个快照:O(用户数),对所有已有用户和新用户重新执行 auth_id_cipher 与 auth_id_decipher。 已有用户保留,新用户为 0 单元测试;只应在配置阶段使用

add 以阻塞的 lock() 获取 resort_lock,因此不会与重排交错:两者都不会发布一个丢掉对方改动的快照。新用户追加在扫描顺序的末尾。

字段 原语 写入方 读取方
snapshot ArcSwap<Snapshot<T>> add、maybe_resort,均在 resort_lock 下 authenticate,不加锁
replay [Mutex<ReplayShard>; 64] authenticate,每次调用一个分片 authenticate
deep_hits AtomicU64,Relaxed authenticate(fetch_add)、maybe_resort(重置) authenticate、maybe_resort
resort_lock Mutex<()> add(lock)、maybe_resort(try_lock) 无
Snapshot::hits 每用户一个 AtomicU64,Relaxed authenticate reordered_by_hits、add

authenticate 接收 &self,因此一个入站的所有连接共享同一个 Arc<AccountValidator<T>>。

不变量 保证方 测试
KDF 的基础层是以 "VMess AEAD KDF" 为密钥的普通 HMAC-SHA256。 第 0 层的 hmac_chain hmac_base_level_matches_reference(protocols/tests/unit/vmess/aead.rs)
认证 ID 经其用户的密码器往返后,CRC 有效且时间戳不变。 create_auth_id_with_cipher、AuthIdPlain auth_id_roundtrips_within_window(aead.rs 测试)
用户能匹配自己新生成的 ID。 decipher_matches user_found_for_own_authid_within_window(protocols/tests/unit/vmess/accounts.rs)
用其他用户密钥生成的 ID 永远不会匹配。 AuthIdPlain::crc_ok user_not_found_for_wrong_id(accounts.rs 测试)
时间戳超出 ±120 秒的 ID 会被拒绝,过去和未来两个方向都是。 decipher_matches 中的 AUTHID_WINDOW_SECS user_not_found_outside_time_window(accounts.rs 测试)。测试使用偏差 1000 秒的 ID,因此恰好 120 秒的边界没有被测试覆盖。
同一 ID 的第二次使用会被拒绝。 ReplayShard::admit replayed_authid_is_rejected(accounts.rs 测试);a_replayed_auth_id_is_refused(protocols/tests/unit/vmess/core.rs,期望 PermissionDenied)
其他流量再多也不会淘汰仍有效的重放条目。 按年龄淘汰,使用 REPLAY_TTL replay_survives_heavy_traffic(accounts.rs 测试,20 000 个不同 ID)
超过 REPLAY_TTL 的条目会离开集合。 admit 中的队首过期 expired_ids_leave_the_set(accounts.rs 测试)。该测试调用的是 expire_all(过期循环的仅测试用副本),而不是 admit 本身。
位于列表深处的热点用户会被提到前面,其他用户仍都可匹配。 deep_hits、maybe_resort、reordered_by_hits deep_hits_hoist_the_hot_user_into_the_prefix(accounts.rs 测试)
构造之后添加的用户可以匹配。 add 重建并发布快照 add_after_construction_is_matchable(accounts.rs 测试)
封装后的请求头无论从流还是从 slice 打开,都能还原出原始字节。 seal_vmess_aead_header、两个打开函数 流:header_seal_open_roundtrips(aead.rs 测试)、request_header_roundtrips(protocols/tests/unit/vmess/protocol.rs)。slice:request_and_response_headers_open_from_slices(protocol.rs 测试)、stream_codec_seals_the_header_and_chunks_and_opens_the_response(protocols/tests/unit/vmess/codec.rs)
请求头不完整时,slice 打开函数返回 None 而不是错误,并准确报告用掉的字节数。 open_vmess_aead_header_slice request_and_response_headers_open_from_slices(protocol.rs 测试)
响应头用响应密钥封装和打开,且只有首字节回显了 response_header 时解码器才接受。 encode_response_header、decode_response_header、decode_response_header_slice response_header_roundtrips(protocol.rs 测试,固定密钥);request_and_response_headers_open_from_slices(密钥来自 RequestSession::response)。没有测试输入错误的回显字节。
Shake128 符合 FIPS 202,分次读取与一次性读取结果相同。 keccak_f1600、海绵填充 shake128_fips202_empty、shake128_streaming_matches_bulk(aead.rs 测试)
所有盐值、cmd key 魔数以及 SHAKE 的用法都与 Xray 一致。 从 Xray 照抄的常量 app_server_vmess_grpc_xray_client_tls、app_client_vmess_ws_xray_server_early_data_plain、app_server_vmess_ws_xray_client_early_data_plain(app/tests/integration/e2e_xray_vmess.rs)。它们用 go 从源码构建 Xray;缺少 go 或构建失败时提前返回,并判为通过。
密钥、IV、认证 ID 和解密后的分组不能互相替换。 keys.rs 中各自独立的 newtype;GcmKey 和 GcmNonce 没有公开的字节构造函数 由编译器保证;没有运行时测试

服务端协议核心和客户端 codec 还在 protocols/tests/pipeline/vmess.rs 中通过真实 socket 相互对跑。new_server_vs_new_client_tcp 分别测试带 global padding 的 AES-128-GCM 和不带 global padding 的 ChaCha20-Poly1305;new_server_vs_new_client_udp 测试带 global padding 的 AES-128-GCM。

情况 位置 结果
缓冲不足 16 字节 VMessCore,State::AuthId 消费 0 字节,等待更多数据。
无用户匹配、ID 超出时间窗口或重放 AccountValidator::authenticate 返回 None VMessCore 以 PermissionDenied、vmess: unknown user or invalid auth id 失败。
请求头尚不完整 open_vmess_aead_header_slice 返回 Ok(None) 消费 0 字节,等待更多数据。
长度或载荷的 tag 校验失败 gcm_open InvalidData,vmess: AEAD header open failed。
长度运算溢出 ProtocolError::Overflow("aead header length") InvalidData,integer overflow: aead header length。实际上不可能发生,因为长度是 u16。
流在请求头中途结束 open_vmess_aead_header(read_exact) UnexpectedEof。
响应头 tag 校验失败,或其首字节不一致 decode_response_header、decode_response_header_slice InvalidData(vmess: AEAD header open failed 或 vmess: unexpected response header)。

对请求头大小的输入,gcm_seal 不会失败;万一 AEAD 库返回错误,它会产出空缓冲区而不是 panic。代码在构造上就避免因对端输入而 panic:凡是依赖收到的字节或收到的长度的 slice 访问,都经过 take_array 或 get,对对端提供的长度做的算术运算也都经过检查。剩下的固定偏移下标访问都作用于已知大小的数组。没有测试专门向这些函数输入畸形字节。

握手失败本身不涉及取消问题:除了只等待 read_exact 的 open_vmess_aead_header 之外,本页所有函数都是同步的,而握手超时由协议核心的计时负责(见 服务端协议核心)。

名称 值 位置
TAG_SIZE 16 字节 aead.rs;GCM 和 ChaCha20-Poly1305 的 tag
认证 ID 16 字节 AuthId
ConnNonce 8 字节 aead.rs
封装后的长度字段 18 字节(2 + TAG_SIZE) open_vmess_aead_header_slice → LEN_PART
内层请求头长度 最多 65535 字节(u16) 封装后的长度字段
SHAKE128_RATE 168 字节 aead.rs
next_padding_len 0 到 63 aead.rs
AUTHID_WINDOW_SECS 120 秒,双向 accounts.rs
REPLAY_TTL 240 秒 accounts.rs
REPLAY_SHARDS 64 accounts.rs
REPLAY_MASK 63 accounts.rs
DEEP_HIT_DEPTH 1024 accounts.rs
DEEP_HITS_PER_RESORT 64 accounts.rs
测试 文件 覆盖内容
shake128_fips202_empty protocols/tests/unit/vmess/aead.rs SHAKE128("") 的前 32 个输出字节与 FIPS 202 一致。
shake128_streaming_matches_bulk 同上 48 次两字节读取等于一次 96 字节读取。
hmac_base_level_matches_reference 同上 KDF 第 0 层等于 hmac::SimpleHmac<Sha256>。
auth_id_roundtrips_within_window 同上 CRC 和时间戳在加密、解密后保持不变。
header_seal_open_roundtrips 同上 封装,拆出认证 ID,再用异步打开函数打开。
user_found_for_own_authid_within_window protocols/tests/unit/vmess/accounts.rs 匹配返回正确的 cmd key、UUID 和载荷。
add_after_construction_is_matchable 同上 先 new 再 add,得到可匹配的用户。
user_not_found_outside_time_window 同上 过去和未来 1000 秒的 ID 被拒绝。
user_not_found_for_wrong_id 同上 其他用户的 ID 被拒绝。
replayed_authid_is_rejected 同上 同一 ID 第二次使用返回 None。
replay_survives_heavy_traffic 同上 20 000 个其他 ID 不会淘汰仍有效的条目。
expired_ids_leave_the_set 同上 超过 REPLAY_TTL 的条目被移除。
deep_hits_hoist_the_hot_user_into_the_prefix 同上 1280 个用户;最后一个用户命中 65 次后移到下标 0。
request_header_roundtrips protocols/tests/unit/vmess/protocol.rs 完整请求头经封装和异步打开。
response_header_roundtrips 同上 响应头的封装与打开。
request_and_response_headers_open_from_slices 同上 slice 打开函数处理截断和完整的缓冲区。
stream_codec_seals_the_header_and_chunks_and_opens_the_response protocols/tests/unit/vmess/codec.rs 客户端 codec 生成的请求头能用 open_vmess_aead_header_slice 打开,且 used 指向第一个请求体分块。
a_replayed_auth_id_is_refused protocols/tests/unit/vmess/core.rs 两个协议核心共享一个验证器;重放以 PermissionDenied 失败。
new_server_vs_new_client_tcp, new_server_vs_new_client_udp protocols/tests/pipeline/vmess.rs 服务端协议核心通过 socket 与客户端 codec 对跑。
app_server_vmess_grpc_xray_client_tls 以及两个 WebSocket early-data 测试 app/tests/integration/e2e_xray_vmess.rs 与真实 Xray 二进制的字节级兼容。

accounts.rs 的测试使用 AccountValidator 上仅供测试的辅助函数(user_count、scan_depth、seen_len、expire_all)以及 auth_id_with_nonce;后者按指定的随机字段构造 ID,使测试能在同一秒内生成大量互不相同的有效 ID。用 cargo test -p etemenanki-protocols vmess 运行单元测试。