Shadowsocks 2022
源码文件:24 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/ss_2022/mod.rsEtemenanki/protocols/src/ss_2022/crypto.rsEtemenanki/protocols/src/ss_2022/protocol.rsEtemenanki/protocols/src/ss_2022/users.rsEtemenanki/protocols/src/ss_2022/core.rsEtemenanki/protocols/src/ss_2022/codec.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/helpers/crypto.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/core/harness.rsEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/client.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/unit/ss_2022/protocol.rsEtemenanki/protocols/tests/unit/ss_2022/core.rsEtemenanki/protocols/tests/unit/ss_2022/codec.rsEtemenanki/protocols/tests/pipeline/shadowsocks.rskatana/src/inbound.rskatana/src/outbound/mod.rskatana/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 在自己的入站和出站构建器中,用面板数据构建同样的类型。
加密方式与密钥长度
Section titled “加密方式与密钥长度”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 解码、规范化与折叠
Section titled “PSK 解码、规范化与折叠”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)。
身份子密钥与身份哈希
Section titled “身份子密钥与身份哈希”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 分组"]
AEAD 分块流
Section titled “AEAD 分块流”StreamAead
Section titled “StreamAead”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)。
ChunkWriter 与 ChunkReader
Section titled “ChunkWriter 与 ChunkReader”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 中读取。
RecordDecoder
Section titled “RecordDecoder”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 避免了这一点:
- 没有
pending_len时,它等待 18 字节(2 + TAG_SIZE),复制这些字节,解密副本,并把长度存入pending_len。此时读端的 nonce 已前进,但线上字节保持不变。 - 它等到数据分块完整(
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 字节。
请求(客户端到服务端)
Section titled “请求(客户端到服务端)”| 字段 | 大小 | 含义 |
|---|---|---|
| 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。
响应(服务端到客户端)
Section titled “响应(服务端到客户端)”| 字段 | 大小 | 含义 |
|---|---|---|
| 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,随后检查失败。
多用户:扩展身份头
Section titled “多用户:扩展身份头”多用户服务端使用一个身份 PSK(iPSK)监听,并知道一组用户 PSK(uPSK)。客户端在发送任何 AEAD 分块之前先证明自己是哪个用户:
- 对链中的每一跳,客户端派生
identity_subkey(current_psk, request_salt),取eih_hash(next_psk),并把这 16 字节哈希作为一个 AES 分组加密。链为identity_keys ++ [psk],所以只有一个 iPSK 时只有一个分组:用 iPSK 子密钥加密的 uPSK 哈希。 - 客户端从 uPSK 而不是 iPSK 派生会话子密钥。
- 服务端读取 salt 和一个 16 字节分组,用
decrypt_eih(identity_psk, salt, key_len, &mut block)解密,然后在Validator中查找该哈希。 - 已知的哈希给出该用户的 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 在加载时就会拒绝它。
Validator
Section titled “Validator”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>> 的形式由所有连接的协议核心共享。
客户端的身份链
Section titled “客户端的身份链”出站密码的格式是 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>)。
服务端配置类型
Section titled “服务端配置类型”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 实现保持兼容。
服务端协议核心:Ss2022Core
Section titled “服务端协议核心:Ss2022Core”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。
打开流与嗅探
Section titled “打开流与嗅探”只有入站启用了嗅探、且 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: 记录
流结束与错误
Section titled “流结束与错误”| 事件 | 握手阶段的各状态 | 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:一个从不发送字节的客户端不会产生任何运行时事件,也就永远触发不了协议核心自己的超时。
客户端 codec:Ss2022Stream
Section titled “客户端 codec:Ss2022Stream”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_2022cargo 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。