跳转到内容

Shadowsocks 2022

源码文件:24 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/ss_2022/mod.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/ss_2022/protocol.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/protocols/src/ss_2022/core.rs
  • Etemenanki/protocols/src/ss_2022/codec.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/helpers/crypto.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/core/harness.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/protocols/tests/unit/ss_2022/protocol.rs
  • Etemenanki/protocols/tests/unit/ss_2022/core.rs
  • Etemenanki/protocols/tests/unit/ss_2022/codec.rs
  • Etemenanki/protocols/tests/pipeline/shadowsocks.rs
  • katana/src/inbound.rs
  • katana/src/outbound/mod.rs
  • katana/src/serve.rs

etemenanki_protocols::ss_2022 模块为 TCP 实现了 Shadowsocks 2022(SIP022)。它移植自 sing-shadowsocks/shadowaead_2022,并按本 workspace 的 sans-I/O 模型重新组织:服务端协议核心 Ss2022Core 由 per-connection 运行时驱动,客户端 codec Ss2022Stream 由客户端运行时驱动。两者共用同一组线格式原语:BLAKE3 子密钥、带长度前缀的 AEAD 分块流,以及在多用户服务端上用来包装扩展身份头(Extended Identity Header,EIH)的 AES 分组密码。

本页面向修改该模块或构建它的代码的贡献者,内容细到每个头部的字节布局、两端的状态机,以及固定每条规则的测试。面向用户的配置见 Shadowsocks 指南页;旧版 AEAD 协议族(SIP004)有单独的开发者页面。

仅 TCP 该模块没有数据报路径。app 的 Outbound::Ss2022 对每个 UDP 流都返回 shadowsocks-2022 carries no datagrams(io::ErrorKind::Unsupported)。

关注点 位置 作用
加密方式与密钥长度 crypto.rs → Method 解析三个 2022-blake3-* 名称,并给出密钥(以及 salt)长度。
密钥派生 crypto.rs → session_key、identity_subkey、eih_hash、fold_key BLAKE3 会话子密钥和身份子密钥、16 字节身份哈希,以及对过长 PSK 的 SHA-256 折叠。
AEAD 分块流 crypto.rs → StreamAead、ChunkWriter、ChunkReader、RecordDecoder 以小端序 nonce 计数器加密和解密分块;原地解开带长度前缀的记录。
EIH 分组密码 crypto.rs → BlockCipher 单个原始 AES 分组(ECB),按密钥长度选择 AES-128 或 AES-256。
时间戳窗口 crypto.rs → check_timestamp_at 拒绝与本地时钟相差超过 30 秒的头部时间戳。
头部成帧 protocol.rs 构建和解析请求头与响应头;包装和解开 EIH 分组。
用户 users.rs → Ss2022User、Ss2022ServerConfig、Validator PSK 解码和规范化,以及多用户服务端的身份哈希表。
服务端 core.rs → Ss2022Core sans-I/O 服务端:解析请求、打开流、中继记录、写出与 salt 绑定的响应。
客户端 codec.rs → Ss2022Stream sans-I/O 客户端 codec:写出请求(可带身份链)、加密记录、检查响应回显的 salt。

该模块交给其他部分的事情:

  • I/O、缓冲区和背压属于 etemenanki-concepts 中的运行时(ProxyServerRuntime、ProxyClientRuntime)。协议核心和 codec 只看到字节切片和一块 Staging 区域。
  • 拨号和路由属于 app 的 connector(连接器)。协议核心发出带 Flow 的 Effect::Open,而 codec 在构建时就已得知目标地址。
  • 配置解析属于 app/src/inbound/mod.rs 和 app/src/outbound/mod.rs,只要 method 以 2022- 开头,它们就选用 2022 协议族。katana 在自己的入站和出站构建器中,用面板数据构建同样的类型。
pub enum Method {
Blake3Aes128Gcm,
Blake3Aes256Gcm,
Blake3ChaCha20Poly1305,
}
impl Method {
pub fn from_name(name: &str) -> Option<Self>;
pub fn name(self) -> &'static str;
pub fn key_len(self) -> usize;
}
method Method 变体 key_len()(PSK 与 salt) 分块 AEAD EIH 分组密码 多用户服务端
2022-blake3-aes-128-gcm Blake3Aes128Gcm 16 AES-128-GCM AES-128 支持
2022-blake3-aes-256-gcm Blake3Aes256Gcm 32 AES-256-GCM AES-256 支持
2022-blake3-chacha20-poly1305 Blake3ChaCha20Poly1305 32 ChaCha20-Poly1305 服务端无 不支持

Method::from_name 精确匹配规范名称,因此 2022-BLAKE3-AES-128-GCM 会被拒绝。由于 app 先按 2022- 前缀分派,拼错的 2022 名称会报 inbound <tag>: unknown shadowsocks-2022 method "<name>"(或 outbound <tag>: …),而不会落到旧版协议族。katana 不看前缀:它先尝试精确的 2022 名称,再尝试旧版名称,其他一律拒绝。

密钥长度同时也是 salt 长度:本模块写出或读取的每个 salt 都是 key_len() 字节。

AEAD 常量与加密方式定义在一起:

常量 值 含义
TAG_SIZE 16 AEAD tag 长度,GCM 与 Poly1305 相同。
MAX_PACKET_SIZE 0xFFFF 单个分块可承载的最大明文长度(长度前缀是 u16)。
RECORD_OVERHEAD 2 + TAG_SIZE + TAG_SIZE = 34 一条带长度前缀的记录在数据之外增加的字节数。
SESSION_SUBKEY_CONTEXT "shadowsocks 2022 session subkey" 会话子密钥使用的 BLAKE3 derive_key context。
IDENTITY_SUBKEY_CONTEXT "shadowsocks 2022 identity subkey" EIH 子密钥使用的 BLAKE3 derive_key context。

PSK 以配置中的 base64 文本进入模块,离开时恰好是 key_len() 字节。

pub fn decode_psk(password: &str) -> io::Result<Vec<u8>>;
pub fn normalise_psk(method: Method, psk: Vec<u8>) -> io::Result<Vec<u8>>;
pub fn fold_key(key: &[u8], key_len: usize) -> Vec<u8>;
  • decode_psk 去掉首尾空白,用标准 base64 字母表解码。解码失败时报 decode PSK: <base64 error>(InvalidInput)。
  • normalise_psk 原样保留恰好 key_len() 字节的 PSK,用 fold_key 折叠更长的 PSK,并以 shadowsocks-2022: PSK too short (<len> < <key_len>) 拒绝更短的 PSK。
  • fold_key 即 sing 的 Key(key, keyLength):对整个密钥做 SHA-256,截断为 key_len。因此,给 aes-128 加密方式的 32 字节密钥会被接受,并变成其 SHA-256 摘要的前 16 字节。

在 app 中,每个 PSK 都走这条路径:服务端 PSK 经由 Ss2022ServerConfig::from_password,每个用户经由 Ss2022User::from_password,客户端密码的每一段经由出站构建器。katana 的入站构建器在用户密钥上是例外(见服务端配置类型)。

pub fn session_key(psk: &[u8], salt: &[u8], key_len: usize) -> Vec<u8>;

session_key 计算 blake3::derive_key(SESSION_SUBKEY_CONTEXT, psk || salt),并取前 key_len 字节。BLAKE3 是可扩展输出函数(XOF),32 字节输出的前 16 字节等于读取 16 字节 XOF 的结果,也就是 sing 对 aes-128 的计算方式。

连接的每个方向都有自己的 salt,因而有自己的子密钥:客户端的请求 salt 为上行分块流生成密钥,服务端新生成的响应 salt 为下行分块流生成密钥。两者派生自同一个 PSK:单 PSK 模式下是那一个 PSK,多用户模式下是该用户自己的 PSK(uPSK)。

pub fn identity_subkey(psk: &[u8], salt: &[u8], key_len: usize) -> Vec<u8>;
pub fn eih_hash(psk: &[u8]) -> [u8; 16];

identity_subkey 就是换用 IDENTITY_SUBKEY_CONTEXT 的 session_key。eih_hash 是 blake3::hash(psk) 的前 16 字节,等于 sing 的 blake3.Sum512(psk)[:16]。

flowchart LR
  pw["password(base64)"] --> dec["decode_psk"]
  dec --> norm["normalise_psk"]
  norm --> psk["PSK,key_len 字节"]
  salt["请求或响应 salt"] --> sk["session_key"]
  psk --> sk
  sk --> aead["StreamAead"]
  aead --> cw["ChunkWriter / ChunkReader"]
  ipsk["身份 PSK"] --> ik["identity_subkey"]
  salt --> ik
  ik --> bc["BlockCipher"]
  upsk["用户 PSK"] --> hash["eih_hash"]
  hash --> bc
  bc --> eih["16 字节 EIH 分组"]
pub enum StreamAead {
Aes128(Box<Aes128Gcm>),
Aes256(Box<Aes256Gcm>),
ChaCha(Box<ChaCha20Poly1305>),
}
impl StreamAead {
pub fn try_new(method: Method, key: &[u8]) -> io::Result<Self>;
pub fn seal(&self, nonce: &[u8; 12], buf: &mut [u8]) -> [u8; TAG_SIZE];
pub fn open(&self, nonce: &[u8; 12], buf: &mut [u8], tag: &[u8]) -> io::Result<()>;
}

StreamAead 在运行时于三种 AEAD 之间分派。它们都使用 12 字节 nonce、不带关联数据,tag 为分离的 16 字节。try_new 拒绝长度不对的密钥(invalid aes-128-gcm key length 等),open 把任何 tag 不匹配都映射为 AEAD decryption failed(InvalidData)。

pub struct ChunkWriter {
aead: StreamAead,
nonce: [u8; 12],
}
impl ChunkWriter {
pub fn new(method: Method, key: &[u8]) -> io::Result<Self>;
pub fn seal_chunk(&mut self, plaintext: &[u8]) -> Vec<u8>;
pub fn write_data(&mut self, out: &mut Vec<u8>, data: &[u8]);
pub fn seal_into(&mut self, plaintext: &[u8], out: &mut Staging<'_>) -> Option<()>;
pub fn write_data_into(&mut self, data: &[u8], out: &mut Staging<'_>) -> Option<()>;
}
pub struct ChunkReader {
aead: StreamAead,
nonce: [u8; 12],
}
impl ChunkReader {
pub fn new(method: Method, key: &[u8]) -> io::Result<Self>;
pub fn open_chunk(&mut self, chunk: &[u8]) -> io::Result<Vec<u8>>;
pub fn open_in_place(&mut self, chunk: &mut [u8]) -> io::Result<usize>;
pub async fn read_sized<R>(&mut self, r: &mut R, plaintext_len: usize) -> io::Result<Vec<u8>>
where
R: tokio::io::AsyncRead + Unpin;
pub async fn read_data<R>(&mut self, r: &mut R) -> io::Result<Vec<u8>>
where
R: tokio::io::AsyncRead + Unpin;
}

一个分块是 ciphertext || tag。nonce 从零开始,每加密或解密一个分块后,由 helpers::crypto::increment_le 按小端序计数器递增。一个计数器贯穿整个方向:先是头部分块,然后是数据记录,中间不重置。

一条记录由两个分块组成,即 AEAD(len_u16_be) || AEAD(data):

部分 大小 含义
长度分块 2 + 16 数据分块的明文长度,大端序 u16,已加密。
数据分块 len + 16 数据本身,用下一个 nonce 加密。

写端有两类方法:

  • seal_chunk 和 write_data 分配 Vec<u8> 作为输出。write_data 把数据切成 MAX_PACKET_SIZE 大小的片段,并跳过空数据。protocol.rs 中的头部构建函数使用 seal_chunk;write_data 只在测试中使用。
  • seal_into 和 write_data_into 直接写入运行时的 Staging 区域,空间不足时返回 None。两者都在加密之前检查空间,所以失败的调用不会改变 nonce;write_data_into 在加密长度分块之前就检查整条记录所需的空间(data.len() + RECORD_OVERHEAD),因此绝不会只暂存半条记录。write_data_into 还会拒绝长于 MAX_PACKET_SIZE 的数据(调用方需先切分),对空数据则不暂存任何内容并返回 Some(())。

读端与之对应。open_chunk 把明文复制出来,open_in_place 在字节原处解密并返回明文长度。read_sized 和 read_data 从 AsyncRead 中读取。

pub enum RecordStep {
NeedMore,
Data {
consumed: usize,
plain: Range<usize>,
},
}
pub struct RecordDecoder {
reader: ChunkReader,
pending_len: Option<usize>,
}
impl RecordDecoder {
pub fn new(reader: ChunkReader) -> Self;
pub fn reader_mut(&mut self) -> &mut ChunkReader;
pub fn open(&mut self, wire: &mut [u8]) -> io::Result<RecordStep>;
}

运行时契约规定:只要协议核心没有消费,运行时就会把同样的未消费字节(后面追加更多数据)再次交给它。如果原地解密了长度分块后返回 NeedMore,下一次调用拿到的就是已经解密过的字节。RecordDecoder 避免了这一点:

  1. 没有 pending_len 时,它等待 18 字节(2 + TAG_SIZE),复制这些字节,解密副本,并把长度存入 pending_len。此时读端的 nonce 已前进,但线上字节保持不变。
  2. 它等到数据分块完整(18 + len + 16 字节)后原地解密该分块,清空 pending_len,并返回 Data { consumed, plain },其中 plain 指向切片内部。

空记录(len == 0)是合法的,得到的 plain 为空;协议核心会跳过空范围,而不是转发它们。本模块自己的写端从不产生空记录,因为 write_data 和 write_data_into 会跳过空数据。

地址使用 AddressCodec::SOCKS:一个类型字节(0x01 IPv4、0x03 域名、0x04 IPv6),接着是地址(域名带一个字节的长度),然后是大端序端口。最长编码为 AddressCodec::MAX_LEN = 259 字节。

字段 大小 含义
Salt key_len 随机生成。通过 session_key(psk, salt) 为上行分块流生成密钥。
EIH 分组 16 × 跳数 仅多用户模式。客户端链中每个身份密钥对应一个 AES 分组。
固定头分块 11 + 16 以 nonce 0 加密:类型、时间戳和可变头长度。
可变头分块 var_len + 16 以 nonce 1 加密:地址、填充和初始负载。
记录 重复 AEAD(len) 后跟 AEAD(data),nonce 为 2、3、…

固定头(REQUEST_FIXED_LEN = 11 字节明文):

字段 大小 含义
类型 1 HEADER_TYPE_CLIENT = 0。其他值报 shadowsocks-2022: bad header type (expected client)。
时间戳 8 Unix 秒数,大端序 u64。按 30 秒窗口检查。
可变头长度 2 大端序 u16:可变头分块的明文长度。

可变头:

字段 大小 含义
地址 7、19 或 4 + 域名长度 SOCKS 风格的目标地址和端口。
填充长度 2 大端序 u16。
填充 填充长度 零字节,忽略。
初始负载 剩余部分 发往目标的首批负载字节,可以为空。

只要初始负载短于 900 字节,build_request 就填充 1..=MAX_PADDING_LENGTH(900)范围内的随机长度,否则不填充。解析端不限制填充长度,只要求 after_addr + padding_len 落在分块之内,否则报 shadowsocks-2022: padding exceeds request header。分块短到放不下填充长度字段时,报 shadowsocks-2022: truncated request header。

字段 大小 含义
Salt key_len 新生成的随机 salt。为下行分块流生成密钥。
固定头分块 key_len + 11 + 16 以 nonce 0 加密:类型、时间戳、请求 salt 和首个负载长度。
首个负载分块 len + 16 以 nonce 1 加密。始终存在,可能为空。
记录 重复 AEAD(len) 后跟 AEAD(data),nonce 为 2、3、…

响应固定头(response_fixed_len(key_len) = key_len + 11 字节明文):

字段 大小 含义
类型 1 HEADER_TYPE_SERVER = 1。其他值报 shadowsocks-2022: bad header type (expected server)。
时间戳 8 Unix 秒数,大端序 u64。按 30 秒窗口检查。
请求 salt key_len 本响应所回应请求的 salt。
长度 2 大端序 u16:首个负载分块的明文长度。

回显的请求 salt 把响应与请求绑定在一起。客户端用常数时间比较 helpers::crypto::ct_eq 比对它,只要有任何差异就报 shadowsocks-2022: response salt does not match request salt。

pub fn now_unix() -> u64;
pub fn check_timestamp(epoch: u64) -> io::Result<()>;
pub fn check_timestamp_at(epoch: u64, now: u64) -> io::Result<()>;

当 |now - epoch| 超过 30 秒时,check_timestamp_at 报 shadowsocks-2022: bad timestamp;恰好相差 30 秒则通过。两个方向都会检查:服务端协议核心检查请求的固定头,客户端 codec 检查响应的固定头。服务端协议核心以 fn() -> u64 的形式接收时钟,方便测试提供固定时间;客户端 codec 始终使用 now_unix。如果系统时钟早于 Unix 纪元,now_unix 返回 0,随后检查失败。

多用户服务端使用一个身份 PSK(iPSK)监听,并知道一组用户 PSK(uPSK)。客户端在发送任何 AEAD 分块之前先证明自己是哪个用户:

  1. 对链中的每一跳,客户端派生 identity_subkey(current_psk, request_salt),取 eih_hash(next_psk),并把这 16 字节哈希作为一个 AES 分组加密。链为 identity_keys ++ [psk],所以只有一个 iPSK 时只有一个分组:用 iPSK 子密钥加密的 uPSK 哈希。
  2. 客户端从 uPSK 而不是 iPSK 派生会话子密钥。
  3. 服务端读取 salt 和一个 16 字节分组,用 decrypt_eih(identity_psk, salt, key_len, &mut block) 解密,然后在 Validator 中查找该哈希。
  4. 已知的哈希给出该用户的 uPSK、标签和负载。服务端从这个 uPSK 派生会话子密钥,再继续处理固定头。未知的哈希报 shadowsocks-2022: unknown identity。
pub fn decrypt_eih(
identity_psk: &[u8],
salt: &[u8],
key_len: usize,
eih: &mut [u8; 16],
) -> io::Result<()>;

BlockCipher,以及多用户为何需要 AES 加密方式

Section titled “BlockCipher,以及多用户为何需要 AES 加密方式”
pub enum BlockCipher {
Aes128(Box<aes::Aes128>),
Aes256(Box<aes::Aes256>),
}
impl BlockCipher {
pub fn try_new(key: &[u8]) -> io::Result<Self>;
pub fn encrypt_block(&self, block: &mut [u8]);
pub fn decrypt_block(&self, block: &mut [u8]);
}

EIH 是单个原始 AES 分组,所以身份子密钥必须是 AES 密钥。BlockCipher::try_new 按密钥长度(16 或 32 字节)选择 AES-128 或 AES-256,其他长度以 invalid AES key length 拒绝。encrypt_block 和 decrypt_block 变换切片的前 16 字节,对更短的切片不做任何处理。

SIP022 只为 AES-GCM 加密方式定义了 EIH。Validator::from_config 在服务端强制执行这一点:带用户且使用 2022-blake3-chacha20-poly1305 的配置会报 shadowsocks-2022: multi-user requires an aes-gcm method,因此 app 在加载时就会拒绝它。

pub struct Validator<T> {
pub identity_psk: Vec<u8>,
pub hash_to_user: HashMap<[u8; 16], usize>,
pub users: Vec<(Vec<u8>, CompactString, Arc<T>)>,
}
impl<T> Validator<T> {
pub fn from_config(config: &Ss2022ServerConfig<T>) -> io::Result<Option<Self>>;
pub fn resolve(&self, hash: &[u8; 16]) -> Option<(Vec<u8>, CompactString, Arc<T>)>;
}

对没有用户的配置(单 PSK 模式),from_config 返回 Ok(None)。否则,它建立从 eih_hash(uPSK) 到 users 下标的 hash_to_user,并把配置的 psk 作为 identity_psk。resolve 只是一次哈希表查找。validator 每个入站只构建一次,以 Arc<Validator<T>> 的形式由所有连接的协议核心共享。

出站密码的格式是 iPSK:...:uPSK。app(app/src/outbound/mod.rs 中的 "shadowsocks" 分支)和 katana 的出站构建器按 : 切分密码,每一段都经过 decode_psk 和 normalise_psk,取出最后一段作为会话 PSK,其余部分作为 identity_keys 传入:

impl Ss2022Stream {
pub fn new(
method: Method,
psk: Vec<u8>,
identity_keys: Vec<Vec<u8>>,
dest: &Destination,
) -> Self;
}
密码 psk identity_keys 线上的 EIH 分组
uPSK uPSK 空 无
iPSK:uPSK uPSK [iPSK] 1
iPSK1:iPSK2:uPSK uPSK [iPSK1, iPSK2] 2

空段(a::b,或空密码)解码为零字节,报 shadowsocks-2022: PSK too short (0 < <key_len>)。

pub struct Ss2022User {
pub psk: Vec<u8>,
pub email: String,
}
impl Ss2022User {
pub fn new(method: Method, psk: Vec<u8>) -> io::Result<Self>;
pub fn from_password(method: Method, password: &str, email: String) -> io::Result<Self>;
}
pub struct Ss2022ServerConfig<T> {
pub method: Method,
pub psk: Vec<u8>,
pub psk_data: Arc<T>,
pub users: Vec<(Ss2022User, Arc<T>)>,
}
impl<T> Ss2022ServerConfig<T> {
pub fn new(method: Method, psk: Vec<u8>, data: Arc<T>) -> io::Result<Self>;
pub fn from_password(method: Method, password: &str, data: Arc<T>) -> io::Result<Self>;
}

psk 有两种含义,取决于 users 是否为空:

模式 users psk 表示 会话 PSK 流的用户
单 PSK 空 会话 PSK psk 空用户名,psk_data
多用户 非空 身份 PSK(iPSK) 匹配用户的 psk 以该用户的 email 为用户名,该用户的负载

T 是带入 Flow 的每用户负载:app 使用 (),katana 使用它自己的用户标签。katana 总是构建多用户形式,并取用户面板 UUID 字符串的前 key_len() 字节作为 uPSK。它会拒绝没有用户的节点(shadowsocks node requires at least one user),以及 UUID 字符串短于 key_len() 的用户(shadowsocks-2022 user <uid> key too short (< <key_len>))。

这些结构体字段都是公开的,测试和 katana 会直接构建 Ss2022User 值。只有构造函数会规范化 PSK。下游没有任何地方检查长度,所以自行填写字段的代码必须提供恰好 key_len() 字节的密钥,才能与其他 SIP022 实现保持兼容。

pub struct Ss2022Core<T> {
config: Arc<Ss2022ServerConfig<T>>,
validator: Option<Arc<Validator<T>>>,
sniff: bool,
source: Option<IpAddr>,
now: fn() -> u64,
timing: Timing,
state: State,
session: Option<(Vec<u8>, NetworkUser<T>)>,
request_salt: Vec<u8>,
reader: Option<ChunkReader>,
records: Option<RecordDecoder>,
writer: Option<ChunkWriter>,
prefix: SniffPrefix,
flow: Option<Flow<T>>,
}
impl<T> Ss2022Core<T> {
pub const BUF_SIZE: usize = 32 * 1024;
pub fn new(
config: Arc<Ss2022ServerConfig<T>>,
validator: Option<Arc<Validator<T>>>,
sniff: bool,
source: Option<IpAddr>,
now: fn() -> u64,
) -> Self;
pub fn with_system_clock(
config: Arc<Ss2022ServerConfig<T>>,
validator: Option<Arc<Validator<T>>>,
sniff: bool,
source: Option<IpAddr>,
) -> Self;
pub fn is_established(&self) -> bool;
}
impl<T: Send + Sync + 'static> ProxyCoreDecode for Ss2022Core<T> {
type Key = Single;
type Target = Flow<T>;
type Error = io::Error;
type TransportAddr = ();
const STAGING_RESERVE: usize = 32 + 1 + 8 + 32 + 2 + 2 * TAG_SIZE + RECORD_OVERHEAD + 115;
// ...
}

该协议核心对用户负载 T 泛型,只有一个出站(Single),运行在字节流之上(TransportAddr = ())。app 的 serve.rs 为每个接受的连接用 Ss2022Core::with_system_clock(config.clone(), validator.clone(), sniff, source) 构建一个协议核心,并用缓冲区大小为 Ss2022Core::<()>::BUF_SIZE 的 ProxyServerRuntime 驱动它。katana 的 serve.rs 做法相同,只是以其用户标签作为 T。

enum State {
Salt,
Fixed,
Variable(usize),
Sniff,
Relay(Passthrough<Single>),
Done,
}
stateDiagram-v2
  [*] --> Salt
  Salt --> Fixed: 已解析 salt 与 EIH,reader 已生成密钥
  Fixed --> Variable: 固定头分块已解密,时间戳在窗口内
  Variable --> Relay: 流已打开
  Variable --> Sniff: 嗅探 IP 目标,判定为 More
  Sniff --> Relay: 找到 host、预算耗尽、超时或 EOF
  Salt --> Done: TransportEof
  Fixed --> Done: TransportEof
  Variable --> Done: TransportEof
  Relay --> [*]: 两个半边都已关闭、空闲超时或出站消失
  Done --> [*]

每个 Event::Transport 和 Event::Outbound 都会先调用 Timing::touch:在第一个字节事件时启动 HANDSHAKE_TIMEOUT,在中继期间刷新 RELAY_IDLE_TIMEOUT。每个状态只消费完整的单元,在凑齐之前一直返回 Ok(0):

状态 等待 动作
Salt key_len 字节,有 validator 时再加 16 字节 解析出会话 PSK 和用户(单 PSK,或经由 decrypt_eih 与 Validator::resolve),用 session_key(psk, salt) 构建 ChunkReader,并记下请求 salt。
Fixed REQUEST_FIXED_LEN + TAG_SIZE = 27 字节 原地解密分块,检查类型,用 (self.now)() 检查时间戳,记下 var_len。
Variable(var_len) var_len + TAG_SIZE 字节 解密分块,解析目标地址和负载范围,构建 Flow,把 reader 移入 RecordDecoder,然后打开流或开始嗅探。
Sniff 完整记录 把每条记录的明文交给 SniffPrefix::push,直到判定结果不是 More,然后用嗅探结果打开流。
Relay 完整记录 每条非空记录发出一个 Effect::Forward,其范围指向传输层切片内部。
Done 无 不消费任何字节。

服务端在收到完整单元之前不解密任何内容,因此运行时可以安全地把同样的字节再交回来。Fixed 和 Variable 状态只有在 data.get_mut(..len) 返回完整分块时才解密;记录状态则依赖 RecordDecoder。

只有入站启用了嗅探、且 sniff::worth_sniffing 接受该目标(即目标是 IP 地址)时,才会进行嗅探。此时:

  • 可变头中的初始负载进入 SniffPrefix。如果收集器还需要更多数据,协议核心进入 State::Sniff,Timing::enter(Phase::Sniff) 启动 SNIFF_TIMEOUT(300 ms)。
  • 在 State::Sniff 中,每条记录的明文都进入 prefix。prefix 总共最多接收 SNIFF_LIMIT(4 KiB)。
  • 判定结果一旦不是 More,open_sniffed 就把结果复制到 flow.sniffed,发出 Effect::Open,接着对收集到的全部内容发出 Effect::ForwardHeld,再对最后一条记录中 prefix 没有接收的字节发出 Effect::Forward。
  • 遇到带 Expired::Sniff 的 Event::Deadline 或 Event::TransportEof 时,用 prefix 当前持有的内容打开流。

不嗅探时,协议核心发出 Effect::Open;如果初始负载非空,还会对它在可变头分块中的范围发出一个 Effect::Forward,不做复制。打开流后,协议核心进入 State::Relay(Passthrough::new(Single)) 和 Phase::Relay,is_established() 变为 true。中继阶段的第一个字节事件会调用 SniffPrefix::clear,此时运行时已经执行了引用这些暂存字节的 ForwardHeld。

在目标应答之前,协议核心不写出任何内容。中继阶段的第一个 Event::Outbound 调用 start_response,后者以前 min(len, MAX_PACKET_SIZE) 字节作为首个负载调用 build_response(method, session_psk, request_salt, first),暂存结果,并保留返回的 ChunkWriter。该事件的剩余部分以及之后的每个事件,都通过 write_data_into 按 MAX_PACKET_SIZE 切片写成记录。中继之前收到的 Event::Outbound 会被确认并丢弃:那时还没有能产生它的出站。

如果目标什么都没发就关闭,Event::OutboundEof 会在半关闭之前暂存一个首个负载为空的响应,这样在接受流结束之前先读取响应头的客户端仍能拿到完整的头部。(Ss2022Stream 本身也能接受在任何响应字节之前的直接流结束:客户端运行时把没有未解析字节的文件结束视为正常结束。)

sequenceDiagram
  participant C as Ss2022Stream
  participant S as Ss2022Core
  participant T as 目标
  C->>S: salt、EIH、固定头分块、可变头分块
  Note over S: 解析用户,检查时间戳,解析地址
  S->>T: Effect::Open,然后 Forward 初始负载
  C->>S: 记录
  S->>T: 每条记录一个 Effect::Forward
  T-->>S: 首批下行字节
  S-->>C: 响应 salt、带请求 salt 的固定头分块、首个负载
  T-->>S: 更多字节
  S-->>C: 记录
事件 握手阶段的各状态 Sniff 中 Relay 中
TransportEof State::Done,Effect::Finish 打开流,然后 Passthrough::on_transport_eof Passthrough::on_transport_eof:关闭出站的写半边
OutboundEof 忽略 忽略 若尚未发送响应头则发送空响应头,然后 Passthrough::on_outbound_eof
ConnectFailed、OutboundError 以 debug 级别记录,别无其他 以 debug 级别记录,别无其他 以 debug 级别记录,然后 Passthrough::on_outbound_gone:关闭传输层并结束
Deadline Expired::Handshake 返回 handshake_timed_out()(client did not complete its request in time,TimedOut) Expired::Sniff 打开流 Expired::Idle:Timing 已经发出了 Effect::Finish
Connected、Datagram、SendFailed、TransportDatagram、TransportSendFailed 忽略 忽略 忽略

出站事件只可能在 Effect::Open 之后到达,而 Effect::Open 会让协议核心进入 Relay,所以实际上前两列永远不会遇到它们。

handle 返回的任何 Err 都会结束连接:AEAD 解密失败、头部类型错误、时间戳超出窗口、未知身份、地址或填充格式错误,或者在暂存空间低于声明的预留量时(理论上)出现的 staging_full()。运行时把协议核心错误包装为 proxy core: <error>,app 的 serve.rs 以 debug 级别记录为 shadowsocks-2022 connection from <source> ended: proxy core: <error>,其中 <source> 是按 Option 格式化的客户端 IP(Some(…))。在协议核心尚未建立时,app 的 drive 循环对每一步运行时操作最多等待 HANDSHAKE_TIMEOUT,超时则报 inbound handshake timed out after 10s:一个从不发送字节的客户端不会产生任何运行时事件,也就永远触发不了协议核心自己的超时。

pub struct Ss2022Stream {
method: Method,
psk: Vec<u8>,
identity_keys: Vec<Vec<u8>>,
dest: Destination,
request_salt: Vec<u8>,
writer: Option<ChunkWriter>,
down: Down,
}
impl ProxyCoreEncodeHandshake for Ss2022Stream {
type Target = Destination;
type Error = io::Error;
const STAGING_RESERVE: usize = 2048;
fn start(&mut self, out: &mut Staging<'_>) -> io::Result<Handshake>;
fn reply(&mut self, _: &mut [u8], _: &mut Staging<'_>) -> io::Result<Reply>;
fn finish(&mut self, _: &mut Staging<'_>) -> io::Result<()>;
}
impl ProxyCoreEncode for Ss2022Stream {
fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> io::Result<usize>;
fn open(&mut self, wire: &mut [u8]) -> io::Result<Opened>;
}
  • start 以空初始负载调用 build_request,暂存请求头,保留写端和请求 salt,并返回 Handshake::Done:明文可以立即跟上,无需往返。由于负载为空,请求总是带 1 到 900 字节的填充。
  • 对 Done 握手,reply 永远不会被调用,若被调用则返回错误。finish 不暂存任何内容:SIP022 没有关闭帧,所以由客户端运行时对传输层做半关闭。
  • seal 最多接收 MAX_PACKET_SIZE 字节,并用 write_data_into 写出一条记录。客户端运行时最多提供 room - STAGING_RESERVE 字节,所以记录总能放下;空间不足只可能是运行时的 bug,此时返回 shadowsocks-2022: staging room below the declared reserve。在 start 之前调用 seal 会返回 shadowsocks-2022: sealed before start。
enum Down {
Salt,
Fixed(ChunkReader),
FirstPayload(ChunkReader, usize),
Records(RecordDecoder),
}
stateDiagram-v2
  [*] --> Salt
  Salt --> Fixed: key_len 字节,用响应 salt 为 reader 生成密钥
  Fixed --> FirstPayload: 已检查类型、时间戳和回显的 salt
  FirstPayload --> Records: 首个负载已解密
  Records --> Records: 每次调用一条记录

open 用 std::mem::replace 取出状态,并在每次 NeedMore 时放回,所以部分读取不会改变 codec 的位置。每一步都返回 Opened::Frame:

状态 消费 plain
Salt key_len 空
Fixed response_fixed_len(key_len) + TAG_SIZE 空
FirstPayload first_len + TAG_SIZE 首个负载,可能为空
Records 一条记录 该记录的数据,可能为空

codec 从不返回 Opened::End:流在传输层的文件结束处结束。出错后 codec 停留在 Down::Salt;运行时会拆除连接,不再调用它。

不变量 由谁保证 由谁固定
每个方向都有自己的 salt、子密钥和 nonce 计数器;计数器从零开始,贯穿头部分块和记录。 session_key,以零 nonce 创建的 ChunkWriter::new 和 ChunkReader::new,每个分块之后的 increment_le tcp_header_roundtrip_all_methods(protocols/tests/unit/ss_2022/protocol.rs)
只有回显了请求 salt 的响应才被接受。 parse_response_fixed,加上 Ss2022Stream::open 和 read_response 中的 ct_eq response_salt_binding_is_checked(protocols/tests/unit/ss_2022/protocol.rs),request_header_then_records_and_a_bound_response(protocols/tests/unit/ss_2022/codec.rs)
与本地时钟相差超过 30 秒的头部被拒绝。 Fixed 状态和 Down::Fixed 中的 check_timestamp_at eih_selects_the_user_and_a_stale_timestamp_is_refused(protocols/tests/unit/ss_2022/core.rs),request_and_response_headers_decode_from_slices(protocols/tests/unit/ss_2022/protocol.rs)
头部类型字节与方向一致。 parse_request_fixed、parse_response_fixed 每个往返测试都会覆盖;没有专门的负向测试。
运行时再次交回未消费切片时,不会有字节被解密两次。 Fixed、Variable 状态和 Down 中的完整分块检查;记录由 RecordDecoder::pending_len 保证 request_and_response_headers_decode_from_slices(依次喂入一条记录的 10 字节、20 字节和全部字节),single_psk_request_opens_with_its_first_payload(40 字节的切片只取出 salt),request_header_then_records_and_a_bound_response(部分响应返回 NeedMore)
多用户服务端按 EIH 选择用户,并拒绝未知身份。 on_salt 中的 decrypt_eih 和 Validator::resolve eih_selects_the_user_and_a_stale_timestamp_is_refused、eih_decrypts_to_the_user_hash、tcp_eih_roundtrip_multi_user
多用户需要 AES 加密方式。 Validator::from_config 没有自动化测试;对配置运行 etemenanki-app --test 可以看到该错误。
构造之后,PSK 恰好是 key_len() 字节。 每个构造函数中的 normalise_psk 没有自动化测试;对配置运行 etemenanki-app --test 可以看到该错误。
暂存加密失败时不改变 nonce,也绝不暂存半条记录。 seal_into 和 write_data_into 开头的空间检查 没有专门测试。
即使目标一言不发,客户端也总能看到响应头。 OutboundEof 时的 start_response(&[]) a_target_that_never_answers_still_gets_an_empty_response_header(protocols/tests/unit/ss_2022/core.rs)
嗅探到的字节只转发一次,且排在其余数据之前。 SniffPrefix 加 Effect::ForwardHeld,并用 took 偏移下一个 Forward sniffing_reads_records_until_a_host_appears(protocols/tests/unit/ss_2022/core.rs)
名称 值 位置 含义
Ss2022Core::BUF_SIZE 32 KiB core.rs 服务端读缓冲区。头部分块或记录必须能放下,否则运行时以 FrameTooLarge(protocol frame exceeds the read buffer)使连接失败。
SS2022_BUF 32 KiB app/src/outbound/mod.rs Ss2022Stream 的客户端运行时缓冲区(katana 的出站使用相同大小)。响应头分块或记录必须能放下,否则客户端运行时报 upstream frame larger than the client runtime's buffer。
Ss2022Core::STAGING_RESERVE 256 字节 core.rs 每个事件在负载之外保证可用的空间。该表达式由不含负载的最大响应头(32 字节 salt、1 + 8 + 32 + 2 字节固定头、两个 tag:共 107 字节)、一条记录的开销(34)和 115 字节余量相加而成,这样第一个下行事件既能暂存响应头,也能暂存其后的一条记录。
Ss2022Stream::STAGING_RESERVE 2048 字节 codec.rs 容纳带满额填充和身份链的请求头,或一条记录的开销。
MAX_PACKET_SIZE 65 535 字节 crypto.rs 每个分块的最大明文长度。
MAX_PADDING_LENGTH 900 字节 protocol.rs build_request 写入填充的上限。
时间戳窗口 ±30 s check_timestamp_at 包含边界。
HANDSHAKE_TIMEOUT 10 s protocols/src/core/mod.rs 协议核心从第一个字节事件到请求解析完毕的超时(之后嗅探改用 SNIFF_TIMEOUT);在协议核心建立之前,app 的 drive 循环也以它作为每一步的等待时间。
SNIFF_TIMEOUT 300 ms protocols/src/sniff/mod.rs 请求之后的嗅探窗口。
SNIFF_LIMIT 4 KiB protocols/src/sniff/mod.rs 嗅探器跨记录检查的字节数。
RELAY_IDLE_TIMEOUT 300 s protocols/src/core/mod.rs 中继期间两个方向都没有字节时的超时。

app 在加载时就构建这些类型,因此 etemenanki-app --test -c <file> 无需启动监听器即可显示这些错误:

条件 错误
未知的 2022- 加密方式,或大小写不对 inbound <tag>: unknown shadowsocks-2022 method "<name>"
密码不是有效的 base64 decode PSK: <base64 error>
PSK 短于密钥长度 shadowsocks-2022: PSK too short (<len> < <key_len>)
带用户且使用 2022-blake3-chacha20-poly1305 shadowsocks-2022: multi-user requires an aes-gcm method

长于密钥长度的 PSK 会被接受并折叠,详见 PSK 解码。

单元测试通过 #[path] 模块编译进库中。pipeline 测试在真实 socket 上运行服务端协议核心和客户端 codec。

终端窗口
cargo test -p etemenanki-protocols --lib ss_2022
cargo test -p etemenanki-protocols --test pipeline ss2022
测试 文件 固定的行为
tcp_header_roundtrip_all_methods protocols/tests/unit/ss_2022/protocol.rs 对全部三种加密方式,请求、一条记录,以及带一条记录的已绑定响应都能通过异步读取函数往返(译自 sing 的 service_test.go)。
response_salt_binding_is_checked protocols/tests/unit/ss_2022/protocol.rs 回显了其他 salt 的响应以 InvalidData 失败。
tcp_eih_roundtrip_multi_user protocols/tests/unit/ss_2022/protocol.rs read_request_multi 解析出正确的用户;不认识该用户的解析器会拒绝请求。
request_and_response_headers_decode_from_slices protocols/tests/unit/ss_2022/protocol.rs 对全部三种加密方式,切片解析函数与构建函数一致;相差 60 秒的请求时间戳无法通过窗口检查;RecordDecoder 对部分切片返回 NeedMore,随后解开整条记录;响应回显请求 salt。
eih_decrypts_to_the_user_hash protocols/tests/unit/ss_2022/protocol.rs 在 iPSK 的身份子密钥下,第一个 EIH 分组解密得到 eih_hash(uPSK)。
single_psk_request_opens_with_its_first_payload protocols/tests/unit/ss_2022/core.rs 协议核心从部分切片中只取出 salt,然后打开流并原地转发初始负载。
eih_selects_the_user_and_a_stale_timestamp_is_refused protocols/tests/unit/ss_2022/core.rs 用户名来自匹配的用户;未知身份和 1_000 的时钟都被拒绝。
sniffing_reads_records_until_a_host_appears protocols/tests/unit/ss_2022/core.rs 分散在头部和一条记录中的 HTTP Host 能被嗅探到;ForwardHeld 覆盖全部 41 个已收集字节。
the_response_opens_with_the_codec_and_binds_the_request_salt protocols/tests/unit/ss_2022/core.rs 协议核心的响应和记录能通过 Ss2022Stream 解码。
a_target_that_never_answers_still_gets_an_empty_response_header protocols/tests/unit/ss_2022/core.rs 没有下行字节时,OutboundEof 暂存一个 107 字节的响应(aes-256:salt、固定头分块、空负载分块)以及 ShutdownTransport。
request_header_then_records_and_a_bound_response protocols/tests/unit/ss_2022/codec.rs codec 的请求可以解析,部分响应返回 NeedMore,首个负载与记录能拼接起来,绑定到其他 salt 的响应会失败。
ss2022_new_server_vs_new_client_tcp protocols/tests/pipeline/shadowsocks.rs 对全部三种加密方式,70 000 字节经服务端运行时和客户端运行时回显,然后正常结束流。
ss2022_multi_user_new_server_vs_new_client protocols/tests/pipeline/shadowsocks.rs 同样的回显,经过多用户服务端和使用 iPSK:uPSK 的客户端。

添加测试时,把负向用例放在正向用例旁边:改动的头部字段、错误的密钥、偏移的时钟。协议核心的测试使用 CoreHarness,它的 feed 会不断把同一切片的未消费尾部交给协议核心,直到协议核心不再消费,并返回总消费量;要测试部分读取,先喂入前缀再喂入剩余部分,就像 single_psk_request_opens_with_its_first_payload 那样。时间戳用例使用带固定时钟的 Ss2022Core::new。