VMess:密钥与认证
源码文件:20 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/vmess/aead.rsEtemenanki/protocols/src/vmess/keys.rsEtemenanki/protocols/src/vmess/accounts.rsEtemenanki/protocols/src/macros.rsEtemenanki/protocols/src/vmess/protocol.rsEtemenanki/protocols/src/vmess/session.rsEtemenanki/protocols/src/vmess/framing.rsEtemenanki/protocols/src/vmess/core.rsEtemenanki/protocols/src/error.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/unit/vmess/aead.rsEtemenanki/protocols/tests/unit/vmess/accounts.rsEtemenanki/protocols/tests/unit/vmess/protocol.rsEtemenanki/protocols/tests/unit/vmess/core.rsEtemenanki/protocols/tests/unit/vmess/codec.rsEtemenanki/protocols/tests/pipeline/vmess.rsEtemenanki/app/tests/integration/e2e_xray_vmess.rskatana/src/inbound.rskatana/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
Section titled “cmd key”每个用户的所有密钥都从账户的 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) -> CmdKeyCmdKey = 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 |
各密钥的来源
Section titled “各密钥的来源”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) -> Aes128pub fn create_auth_id_with_cipher(cipher: &Aes128, time: i64) -> AuthIdpub fn create_auth_id(cmd_key: &CmdKey, time: i64) -> AuthId
pub fn auth_id_decipher(cmd_key: &CmdKey) -> Aes128Decpub fn decode_auth_id_dec(cipher: &Aes128Dec, authid: &AuthId) -> AuthIdPlaincreate_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。
服务端如何校验 ID
Section titled “服务端如何校验 ID”protocols/src/vmess/accounts.rs → decipher_matches 是扫描时对每个用户执行的检查:
- 用该用户的
Aes128Dec解密分组。 AuthIdPlain::crc_ok():存储的 CRC32 必须等于前 12 字节的 CRC32。用错误用户的密钥解密只会得到噪声,它通过此检查的概率为 2^-32。能通过这一步,就确定了 ID 所属的账户。AuthIdPlain::timestamp()不能为负。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。
请求头 AEAD
Section titled “请求头 AEAD”封装后的请求头
Section titled “封装后的请求头”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 绑定。
密钥与 nonce
Section titled “密钥与 nonce”每个密钥都是 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 变化。
带类型的请求头材料
Section titled “带类型的请求头材料”请求头相关类型用 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 值。
服务端握手数据流
Section titled “服务端握手数据流”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
Section titled “SHAKE128”分块帧格式需要 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 时)。顺序很重要:两端消费同一条密钥流,调换这两次读取会让两端从第一个分块起就失去同步。
带类型的密钥
Section titled “带类型的密钥”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]) -> Selfpub 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 摘要,仍保留为原始数组。
AccountValidator
Section titled “AccountValidator”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 的下标。
authenticate
Section titled “authenticate”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
- 在扫描之前,用单调时钟记录到达时间。
- 通过
ArcSwap::load加载当前快照。这一步不加锁,并发的add或重排也无法改变本次调用扫描的快照。 - 扫描。没有匹配则返回
None。 - 根据认证 ID 的首字节选出重放分片,只锁住该分片执行一次
admit。重放则返回None。 - 递增该用户的命中计数,复制出
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。
按命中次数重排
Section titled “按命中次数重排”在扫描前部命中很便宜;在列表深处命中则要为之前的每次探测付出代价。验证器会按命中次数重排快照,但只在热点集合明显偏离前缀时才这样做:
| 常量 | 值 | 作用 |
|---|---|---|
DEEP_HIT_DEPTH |
1024 | 在 idx >= 1024 处的匹配算作深层命中。前缀内的命中永远不会进入重排路径。 |
DEEP_HITS_PER_RESORT |
64 | 在尝试重排之前,deep_hits 中需累积的深层命中数。 |
当某次深层命中使 deep_hits 达到 64 或以上时,当前线程调用 maybe_resort:
resort_lock.try_lock()。如果其他线程持有该锁,立即返回;扫描路径从不因重排而阻塞。- 在锁内重新检查
deep_hits(其他线程可能刚完成重排),然后把它重置为 0。 reordered_by_hits按命中次数降序排列(decipher, meta, hits)三元组(使用sorted_unstable_by_key,因此命中数相同者顺序不定),并克隆到新快照中。它克隆的是已展开的Aes128Dec,而不是重新跑 KDF 和密钥扩展,所以一次重排只需对所有用户做一次排序和一次复制,不涉及任何密钥派生。- 用
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 运行单元测试。