Shadowsocks AEAD
源码文件:21 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/ss_legacy/mod.rsEtemenanki/protocols/src/ss_legacy/aead.rsEtemenanki/protocols/src/ss_legacy/users.rsEtemenanki/protocols/src/ss_legacy/core.rsEtemenanki/protocols/src/ss_legacy/codec.rsEtemenanki/protocols/src/ss_legacy/protocol.rsEtemenanki/protocols/src/helpers/crypto.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/client.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/unit/ss_legacy/aead.rsEtemenanki/protocols/tests/unit/ss_legacy/codec.rsEtemenanki/protocols/tests/unit/ss_legacy/core.rsEtemenanki/protocols/tests/pipeline/shadowsocks.rskatana/src/inbound.rskatana/src/outbound/mod.rskatana/src/serve.rs
本页介绍 etemenanki-protocols 中的旧版 Shadowsocks 实现:SIP004 AEAD 分块流、密钥和 nonce 的派生方式、一个端口如何服务多个用户,以及建立在其上的两个 sans-I/O 半边——服务端协议核心(core)ShadowsocksCore 和客户端 codec SsStream。本页面向修改 protocols/src/ss_legacy/ 的贡献者。Shadowsocks 2022 位于单独的模块,有单独的页面。
仅 TCP 两个半边都不承载 UDP。
ss_legacy 模块负责:
- 四种 AEAD 加密方法及其密钥、salt 和 nonce 长度(
Method); - 每个会话的子密钥和小端序 nonce 计数器(
Session、NonceCounter); - 原地封装和解开分块流,且任何数据都不会被解密两次(
ChunkEncoder、ChunkDecoder); - 每份配置只做一次、把密码列表转换成密钥表(
Resolved); - 以
ProxyCoreDecode实现服务端(ShadowsocksCore),以ProxyCoreEncode实现客户端(SsStream)。
以下内容有意不在本模块中:
| 不在这里 | 实际所在位置 |
|---|---|
流加密、none、plain |
任何地方都不支持;Method::from_name 返回 None,app 和 katana 都会拒绝该配置。 |
| Shadowsocks 2022(SIP022) | protocols/src/ss_2022/,见 Shadowsocks 2022。 |
| UDP 中继 | 未实现;见不支持 UDP。 |
| socket、定时器、缓冲区 | 单连接运行时;见服务端运行时和客户端运行时。 |
| SOCKS 风格的地址编解码 | protocols/src/helpers/address.rs → AddressCodec,与其他协议共用;见协议基础。 |
| 嗅探 | protocols/src/sniff/,见嗅探。 |
| 文件 | 内容 |
|---|---|
protocols/src/ss_legacy/mod.rs |
重新导出 Method、SsStream、ShadowsocksCore、Resolved、ShadowsocksServerConfig、ShadowsocksUser。 |
protocols/src/ss_legacy/aead.rs |
Method、Session、NonceCounter、ChunkEncoder、ChunkDecoder、ChunkStep、各常量,以及异步适配器 EncryptWriter / DecryptReader。 |
protocols/src/ss_legacy/users.rs |
ShadowsocksUser、ShadowsocksServerConfig<T>、UserEntry<T>、Resolved<T>。 |
protocols/src/ss_legacy/core.rs |
ShadowsocksCore<T>,服务端状态机。 |
protocols/src/ss_legacy/codec.rs |
SsStream,客户端 codec。 |
protocols/src/ss_legacy/protocol.rs |
ADDR,即地址编解码器(AddressCodec::SOCKS)。 |
一条 Shadowsocks TCP 连接承载两条相互独立的加密流,每个方向一条。每条流以随机 salt 开头,随后是用该 salt 派生出的子密钥封装的 AEAD 分块。协议没有握手,也没有响应头:客户端一连上就开始发送,服务端发出的第一批字节就是它自己的 salt。
| 字段 | 长度(字节) | 含义 |
|---|---|---|
| Salt | key_len:16 或 32 |
随机值;上行子密钥的 HKDF salt。 |
| 分块 0 | CHUNK_OVERHEAD + 地址长度 |
明文是目标地址。SsStream 在这个分块中只封装地址。 |
| 分块 1 … n | CHUNK_OVERHEAD + 载荷长度 |
载荷,每块最多 MAX_PAYLOAD 字节。 |
| (结束) | 无 | 传输层 EOF。SsStream 不发送终止分块。 |
| 字段 | 长度(字节) | 含义 |
|---|---|---|
| Salt | key_len:16 或 32 |
服务端新生成的随机 salt;下行子密钥的 HKDF salt。在第一个下行字节之前发送;如果目标什么都没发,则在目标 EOF 时发送。 |
| 分块 0 … n | CHUNK_OVERHEAD + 载荷长度 |
来自目标的载荷,每块最多 MAX_PAYLOAD 字节。 |
| (结束) | 无 | 传输层 EOF。ShadowsocksCore 不发送终止分块。 |
| 字段 | 长度(字节) | 含义 |
|---|---|---|
| 加密长度 | 2 | 以大端序 u16 表示的载荷长度,已加密。 |
| 长度 tag | 16(TAG_LEN) |
长度的 AEAD tag。 |
| 加密载荷 | length | 已加密的载荷。 |
| 载荷 tag | 16(TAG_LEN) |
载荷的 AEAD tag。 |
因此每个分块在载荷之外额外占用 CHUNK_OVERHEAD = 2 + 16 + 16 = 34 字节。流中紧跟 salt 的前 18 字节(FIRST_CHUNK_LEN,即加密长度及其 tag)就是服务端用来试解密、识别用户的部分。两次封装都不使用关联数据(associated data)。
在读取一侧,加密长度为零表示流结束:ChunkDecoder 只消耗这 18 字节就返回 ChunkStep::End。ShadowsocksCore 和 SsStream 都从不写出这种分块,两者都以传输层 EOF 结束自己的方向。ChunkEncoder 本身可以封装空分块(单元测试就用它作结束标记),所以是调用方保证它不会出现在线上。
上行明文以 SOCKS 格式的目标地址开头(protocols/src/ss_legacy/protocol.rs → ADDR 即 AddressCodec::SOCKS),其后全部是载荷。
| 字段 | 长度(字节) | 含义 |
|---|---|---|
| 地址类型 | 1 | 0x01 IPv4,0x03 域名,0x04 IPv6。其他值都是错误。 |
| 地址 | 4、1 + n 或 16 | 域名为一个长度字节加 n 字节名称。 |
| 端口 | 2 | 大端序。 |
编码后最长的地址是 AddressCodec::MAX_LEN = 1 + 1 + 255 + 2 = 259 字节。服务端不假定地址会在一个分块内到齐:它会持续累积明文,直到地址能够解析。
密钥、salt 与 nonce
Section titled “密钥、salt 与 nonce”flowchart LR pw["password"] --> evp["EVP_BytesToKey (MD5)"] evp --> master["主密钥,key_len 字节"] salt["salt,key_len 字节"] --> hkdf["HKDF-SHA1,info ss-subkey"] master --> hkdf hkdf --> sub["子密钥,key_len 字节"] sub --> session["Session(AEAD cipher)"] session --> enc["ChunkEncoder + NonceCounter"] session --> dec["ChunkDecoder + NonceCounter"]
protocols/src/helpers/crypto.rs → evp_bytes_to_key 即 OpenSSL 的 EVP_BytesToKey,使用 MD5、迭代一次、不加 salt:D1 = MD5(password),Di = MD5(Di-1 ‖ password),依次拼接后截断到 key_len。对 16 字节密钥,结果就是 MD5(password),evp_key_known_answer 用 "test" 的已知答案固定了这一点。
pub fn evp_bytes_to_key(password: &[u8], key_len: usize) -> Vec<u8>主密钥按配置派生一次,绝不按连接派生:服务端由 Resolved::new 为每个用户派生,客户端由出站构建器派生。
hkdf_sha1_ss_subkey 运行 HKDF-SHA1:以 salt 作为 HKDF salt,主密钥作为输入密钥材料,固定 info 字符串为 ss-subkey,输出 key_len 字节。每次 Session::new 派生一个子密钥并用它实例化 AEAD,因此每条连接的每个方向都有自己的密钥。
pub fn hkdf_sha1_ss_subkey(master_key: &[u8], salt: &[u8], out: &mut [u8])salt 来自 random_salt,它用 rand::rng() 填充 key_len 字节。客户端在 SsStream::start 中生成上行 salt;服务端在首次响应时生成下行 salt。
每个方向有一个 NonceCounter,初始为全零,每次 AEAD 操作后按小端序整数递增(increment_le)。封装或解开一个分块需要两次操作,所以某个方向的第 i 个分块用 nonce 2i 处理长度、用 2i + 1 处理载荷。两个方向既不共用计数器也不共用子密钥,因为各自有自己的 salt 和自己的 Session。
Method |
from_name 接受的名称 |
密钥与 salt(key_len) |
Nonce(nonce_len) |
Tag |
|---|---|---|---|---|
Aes128Gcm |
aes-128-gcm、aead_aes_128_gcm |
16 | 12 | 16 |
Aes256Gcm |
aes-256-gcm、aead_aes_256_gcm |
32 | 12 | 16 |
ChaCha20Poly1305 |
chacha20-poly1305、chacha20-ietf-poly1305、aead_chacha20_poly1305 |
32 | 12 | 16 |
XChaCha20Poly1305 |
xchacha20-poly1305、xchacha20-ietf-poly1305 |
32 | 24 | 16 |
from_name 在匹配前会把参数转为小写,因此 AEAD_CHACHA20_POLY1305 也会被接受;但它不会去除首尾空白。salt 始终与密钥等长:key_len 同时表示两者。method_lengths 固定了其中一部分长度:AES 的密钥长度,以及 ChaCha20 和 XChaCha20 的 nonce 长度。
Method 和 Session
Section titled “Method 和 Session”pub enum Method { Aes128Gcm, Aes256Gcm, ChaCha20Poly1305, XChaCha20Poly1305,}
impl Method { pub const fn key_len(self) -> usize; pub const fn nonce_len(self) -> usize; pub fn from_name(name: &str) -> Option<Self>;}
pub struct Session { cipher: Cipher,}
impl Session { pub fn new(method: Method, master_key: &[u8], salt: &[u8]) -> Self; pub fn zero_nonce(&self) -> NonceCounter; pub fn seal_in_place(&self, nonce: &NonceCounter, buf: &mut [u8]) -> io::Result<[u8; TAG_LEN]>; pub fn open_in_place(&self, nonce: &NonceCounter, buf: &mut [u8], tag: &[u8]) -> io::Result<()>; pub fn matches_first_chunk(&self, chunk: &[u8]) -> bool;}
pub enum NonceCounter { N12([u8; 12]), N24([u8; 24]),}
impl NonceCounter { pub fn increment(&mut self);}
pub fn random_salt(n: usize) -> Vec<u8>;Cipher 是私有类型:每个变体对应一个装箱的 aes-gcm 或 chacha20poly1305 实例。seal_in_place 和 open_in_place 使用分离的 tag,nonce 由调用方管理。NonceCounter 的宽度跟随加密方法(zero_nonce),宽度不匹配时返回 shadowsocks AEAD nonce/cipher mismatch 错误,而不是 panic。解开失败时返回 InvalidData,文本为 shadowsocks AEAD open failed。
matches_first_chunk 是试解密的基础操作。它复制两个加密长度字节,用一个新的全零 nonce 解开这份副本,并报告 tag 是否验证通过;不足 18 字节的分块报告 false。它只取 &self,既不改动分块也不改动任何计数器,因此可以对每个用户的密钥逐一运行而没有副作用。
ChunkEncoder
Section titled “ChunkEncoder”pub struct ChunkEncoder { session: Session, nonce: NonceCounter,}
impl ChunkEncoder { pub fn new(session: Session) -> Self; pub fn seal_into(&mut self, plain: &[u8], out: &mut Staging<'_>) -> Option<()>; pub fn seal_to_vec(&mut self, plain: &[u8], out: &mut Vec<u8>) -> io::Result<()>;}两个入口都通过私有的 seal_frame 恰好封装一个分块:写入大端序长度、封装长度、推进 nonce、复制载荷、封装载荷、推进 nonce。seal_into 直接写入运行时的 Staging 区域;当 plain 超过 MAX_PAYLOAD,或 out.room() 小于 CHUNK_OVERHEAD + plain.len() 时,它返回 None,nonce 保持不变。seal_to_vec 追加到 Vec,载荷过大时返回 InvalidInput(shadowsocks chunk exceeds the payload limit)。切分输入是调用方的事,编码器从不切分。
ChunkDecoder 和 ChunkStep
Section titled “ChunkDecoder 和 ChunkStep”pub enum ChunkStep { NeedMore, Data { consumed: usize, plain: Range<usize> }, End { consumed: usize },}
pub struct ChunkDecoder { session: Session, nonce: NonceCounter, pending_len: Option<usize>,}
impl ChunkDecoder { pub fn new(session: Session) -> Self; pub fn open(&mut self, wire: &mut [u8]) -> io::Result<ChunkStep>;}open 处理 wire 开头的分块。返回 Data 时,载荷已在原地解密,plain 是它在 wire 中的范围,consumed 覆盖整个分块。调用方按范围转发 plain,并前进 consumed 字节。
不会重复解密
Section titled “不会重复解密”服务端运行时每次调用都把整个未解析区域交给 core,未消耗的字节会在下一次调用时连同新追加的数据再次出现(见服务端 core)。如果解码器在原地解密了长度头,却因为分块体尚未到达而返回 NeedMore,下一次调用就会看到已经解密过的字节,并用错误的 nonce 再解密一次。
ChunkDecoder 用两种方式避免这个问题:
- 它从两个加密字节的副本解开长度,从不在 wire 缓冲区中操作,所以
wire里的长度头始终是密文。 - 它把解开的长度记在
pending_len中,并只推进一次 nonce。下一次调用时直接跳到分块体检查。
只有在 HEADER + len + TAG_LEN 字节全部到齐后,载荷才会被原地解开,同一步中清除 pending_len。因此,无论一个不完整的分块被呈现多少次,都只需要解开一次长度。
sequenceDiagram participant R as Runtime participant D as ChunkDecoder R->>D: open(长度头和分块体的前几个字节) D->>D: 从副本解开长度,nonce += 1,pending_len = Some(len) D-->>R: NeedMore R->>D: open(相同字节加上更多分块体) D->>D: pending_len 已设置,跳过长度头 D-->>R: NeedMore R->>D: open(完整分块) D->>D: 原地解开分块体,nonce += 1,pending_len = None D-->>R: Data(consumed 和 plain 范围)
chunk_encoder_and_decoder_agree_and_never_decrypt_twice 针对全部四种方法固定了这一顺序:先给出 39 字节分块的前 20 字节(长度头加两个分块体字节),再给出前 30 字节,然后是完整数据,之后是第二个分块和结束标记。
解码器不会把长度与 MAX_PAYLOAD 比较;它接受声明的任意长度,最大到 0xFFFF。长度由运行时的读缓冲区来限制:放不进缓冲区的分块会让连接失败(见限制)。
用户:ShadowsocksServerConfig、Resolved、UserEntry
Section titled “用户:ShadowsocksServerConfig、Resolved、UserEntry”pub struct ShadowsocksUser { pub password: String, pub email: String,}
pub struct ShadowsocksServerConfig<T> { pub method: Method, pub password: String, pub password_data: Arc<T>, pub users: Vec<(ShadowsocksUser, Arc<T>)>,}
pub struct UserEntry<T> { pub key: Vec<u8>, pub email: CompactString, pub data: Arc<T>,}
pub struct Resolved<T> { pub method: Method, pub users: Vec<UserEntry<T>>,}
impl<T> Resolved<T> { pub fn new(config: &ShadowsocksServerConfig<T>) -> Self;}Resolved::new 在配置阶段一次性派生所有主密钥:
config.users |
Resolved::users |
|---|---|
| 为空 | 一个条目:config.password 的密钥、空的 email,以及 password_data。 |
| 非空 | 每个用户一个条目,按配置顺序排列:该用户 password 的密钥、它的 email 和它的载荷。config.password 被忽略。 |
T 是应用附加的每用户载荷:在 etemenanki-app 中是 (),在 katana 中是用户标签。core 通过流的 NetworkUser::user_data 把它传递下去。服务端在一个入站的所有连接之间共享同一个 Arc<Resolved<T>>;每条连接只读取它。
ShadowsocksCore
Section titled “ShadowsocksCore”pub struct ShadowsocksCore<T> { inner: Arc<Resolved<T>>, sniff: bool, source: Option<IpAddr>, timing: Timing, state: State, matched: Option<Matched<T>>, decoder: Option<ChunkDecoder>, encoder: Option<ChunkEncoder>, held: Vec<u8>, prefix: SniffPrefix, flow: Option<Flow<T>>,}
impl<T> ShadowsocksCore<T> { pub const BUF_SIZE: usize = 20 * 1024; pub fn new(inner: Arc<Resolved<T>>, sniff: bool, source: Option<IpAddr>) -> Self; pub fn is_established(&self) -> bool;}
impl<T: Send + Sync + 'static> ProxyCoreDecode for ShadowsocksCore<T> { type Key = Single; type Target = Flow<T>; type Error = io::Error; type TransportAddr = (); const STAGING_RESERVE: usize = 32 + 2 * CHUNK_OVERHEAD + 28; fn handle( &mut self, event: Event<'_, Self>, fx: &mut Effects<'_, Self>, ) -> Result<usize, io::Error>; fn held(&self) -> &[u8];}| 字段 | 作用 |
|---|---|
inner |
共享的密钥表。 |
sniff、source |
是否对 IP 目标进行嗅探;复制到 Flow 中的客户端地址。 |
timing |
唯一的截止时间,按阶段设置(protocols/src/core/mod.rs → Timing)。 |
state |
下文的私有 State 枚举。 |
matched |
选中用户的主密钥(保留下来用于派生响应子密钥)及其 NetworkUser。 |
decoder |
上行 ChunkDecoder,在匹配到用户时创建。 |
encoder |
下行 ChunkEncoder,随响应 salt 一起创建。 |
held |
地址解析出来之前累积的明文,之后是紧跟在地址后面的载荷。 |
prefix |
为 IP 目标收集前几个载荷字节的 SniffPrefix。 |
flow |
由地址构建、等待 Effect::Open 的 Flow。 |
key 是 Single:一条连接承载一个流,去往一个出站。Timing 进入中继阶段后 is_established 为 true;app 的 serve 循环用它来释放握手许可(permit)。
SsStream
Section titled “SsStream”pub struct SsStream { method: Method, key: Vec<u8>, dest: Destination, encoder: Option<ChunkEncoder>, down: Down,}
impl SsStream { pub fn new(method: Method, key: Vec<u8>, dest: &Destination) -> Self;}
impl ProxyCoreEncodeHandshake for SsStream { type Target = Destination; type Error = io::Error; const STAGING_RESERVE: usize = 32 + CHUNK_OVERHEAD + AddressCodec::MAX_LEN + 61; 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 SsStream { fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> io::Result<usize>; fn open(&mut self, wire: &mut [u8]) -> io::Result<Opened>;}key 是主密钥(密码经 evp_bytes_to_key 得到),由调用方计算。Down 是私有类型:等待响应 salt 时为 Salt,之后为 Chunks(ChunkDecoder)。
EncryptWriter 和 DecryptReader
Section titled “EncryptWriter 和 DecryptReader”pub struct EncryptWriter<W> { /* private */ }impl<W> EncryptWriter<W> { pub fn new(inner: W, session: Session) -> Self;}impl<W: AsyncWrite + Unpin> AsyncWrite for EncryptWriter<W>
pub struct DecryptReader<R> { /* private */ }impl<R> DecryptReader<R> { pub fn new(inner: R, session: Session) -> Self;}impl<R: AsyncRead + Unpin> AsyncRead for DecryptReader<R>这两个类型包装一个已经写出或读完 salt 的 Tokio 流。EncryptWriter 把每次 poll_write 封装成一个最多 MAX_PAYLOAD 字节的分块,并把封装后的字节留在 pending 中直到刷出,因此一个分块总是连续发出。DecryptReader 运行 Length / Payload(len) 阶段状态机,只有在分块边界上遇到 EOF 才视为正常结束;在其他位置遇到 EOF 则返回 UnexpectedEof(truncated shadowsocks chunk)。在当前版本中,除单元测试外没有任何代码使用它们:服务端和客户端都走 sans-I/O 半边。
sequenceDiagram participant C as SsStream (client) participant S as ShadowsocksCore (server) participant O as Outbound C->>S: salt_c,然后分块 0 = 加密后的地址 Note over S: 用前 18 字节逐个尝试用户,构建 ChunkDecoder Note over S: 解开分块直到地址能够解析 S->>O: Effect::Open(带用户和目标的 Flow) C->>S: 分块 1..n(载荷) S->>O: Effect::Forward(明文范围,原地) O-->>S: Event::Outbound(响应字节) S-->>C: salt_s,然后是加密分块 O-->>S: Event::OutboundEof S-->>C: ShutdownTransport(无终止分块)
在 Salt 状态下,core 不消耗任何字节,一直等到手中有 key_len + FIRST_CHUNK_LEN 字节。然后它按顺序遍历 Resolved::users,用每个用户的主密钥和收到的 salt 构建一个 Session,并调用 matches_first_chunk。第一个能验证加密长度的用户胜出:
- 用该密钥和 salt 新建一个
Session,由它创建ChunkDecoder。解码器从 nonce 0 开始,这次真正地再次解开第一个分块。 matched记录该密钥(供之后派生响应子密钥)和一个NetworkUser:其authorization为UserAuthorization::UsernamePassword { username: email, password: "" },user_data为该条目的载荷。密码本身从不离开密钥表。- core 只消耗 salt 并转入
Address;刚才测试过的 18 字节会在下一步中再次解开。
如果没有任何密钥能通过验证,handle 以 PermissionDenied(shadowsocks: no matching user)失败,运行时随即结束连接。
在 Address 状态下,core 从切片开头依次解开分块,并把每个分块的明文追加到 held。每解开一个分块,它就尝试 ADDR.read_slice(&self.held),外面包一层 need_more:地址被截断(解析器返回 UnexpectedEof)表示“继续”,其他解析错误则让连接失败。地址解析成功后,core 把地址之后的字节作为前导载荷,调用 on_address,然后返回已处理到的位置。切片剩余部分会在下一次调用时再次出现。
由于每个分块之后都会重试解析,且地址最多 AddressCodec::MAX_LEN 字节,held 的大小永远不会超过一个分块的明文再加 258 字节。an_address_split_across_chunks_is_reassembled 把地址的前四个字节、地址的其余部分和 GET 分别封装成三个分块,并期望得到一个指向 example.com 的 Open,随后是一个转发 GET 的 Forward。
嗅探或直接打开
Section titled “嗅探或直接打开”on_address 构建 Flow(Flow::new(dest, user, source)),然后作出决定:
- 不嗅探(
sniff为 false,或按worth_sniffing的判断目标是域名):前导载荷成为held,open推送Effect::Open;如果有 held 字节,再对它们推送Effect::ForwardHeld;然后进入Phase::Relay。 - 嗅探 IP 目标:前导载荷进入
prefix。如果嗅探器还需要更多数据,core 进入State::Sniff和Phase::Sniff,后者设置SNIFF_TIMEOUT。如果嗅探预算已用完且还有剩余载荷,已收集的前缀和剩余部分会合并进held,由一个ForwardHeld一起送出。否则,流以设置好的flow.sniffed打开,并持有该前缀。
在 State::Sniff 中,后续每个分块的明文都会推入 prefix。一旦判定结果不再是 More,core 就打开流(先 Open,再对前缀执行 ForwardHeld),并用普通的 Effect::Forward 转发该分块中嗅探器没有取走的部分。sniffing_an_ip_target_waits_for_a_recognisable_prefix 发送一个拆分在两个分块中的 HTTP 请求,期望嗅探出域名 example.com、一个 41 字节的 ForwardHeld,以及中继空闲截止时间。嗅探器本身见嗅探。
held() 在 held 非空时返回 held,否则返回嗅探前缀,因此 ForwardHeld 总是对应打开流时所用的那块缓冲区。两者都会在 Relay 中下一个字节事件开始时清空,运行时的 held 缓冲区固定规则保证了这样做是安全的。
在 State::Relay 中,每次调用都会解开切片中所有完整的分块,并为每个非空载荷推送一个 Effect::Forward,其范围指向切片中明文所在的位置。wire 缓冲区和出站之间没有任何复制。
回程方向上,Event::Outbound 到达 on_outbound。start_response 只暂存一次新的 salt,并用匹配到的密钥和该 salt 创建下行 ChunkEncoder;然后用 chunks(MAX_PAYLOAD) 切分数据,每一段用 seal_into 封装。STAGING_RESERVE 覆盖 salt 加两个分块开销,因为运行时只有在 staging 区还剩 STAGING_RESERVE + n 字节空间时才会交付 n 字节的出站读取,而一次最多 BUF_SIZE(20 480)字节的读取最多切成两个分块。
stateDiagram-v2 [*] --> Salt Salt --> Address: 某个用户密钥验证通过第一个分块 Salt --> [*]: 没有匹配的用户或握手超时 Address --> Relay: 地址已解析,打开 Address --> Sniff: 开启嗅探、IP 地址、嗅探器需要更多数据 Address --> [*]: 分块错误、地址错误或握手超时 Sniff --> Relay: 得出判定、预算用完、截止时间到或 EOF Salt --> Done: 传输层 EOF Address --> Done: 传输层 EOF Relay --> [*]: 两个半边都已关闭、出站消失或空闲超时 Done --> [*]
各事件的处理方式:
| 事件 | Salt / Address |
Sniff |
Relay |
|---|---|---|---|
Transport |
Timing::touch(收到第一个字节时设置 HANDSHAKE_TIMEOUT),然后按上文解析。 |
把明文推入前缀;得出判定时打开。结束分块会打开流并半关闭它。 | 转发载荷。结束分块会调用 Passthrough::on_transport_eof 并丢弃解码器。 |
Outbound |
消耗并丢弃(此时还没有出站)。 | 消耗并丢弃。 | 清空 prefix 和 held,然后封装发往客户端。 |
TransportEof |
转入 Done 并推送 Effect::Finish;没有打开任何东西。 |
用已嗅探到的内容打开,然后半关闭出站。 | on_transport_eof:对出站执行 Effect::Shutdown;出站也结束后执行 Finish。 |
OutboundEof |
忽略。 | 忽略。 | 如果还没发送响应 salt 就先暂存,然后 on_outbound_eof:Effect::ShutdownTransport;如果客户端一侧已经结束,再执行 Finish。 |
ConnectFailed、OutboundError |
以 debug 级别记录日志。 | 以 debug 级别记录日志。 | 记录日志,然后 on_outbound_gone:ShutdownTransport 和 Finish。 |
Deadline |
Expired::Handshake:以 TimedOut 失败。 |
Expired::Sniff:用已收集的内容打开。 |
Expired::Idle:Timing 已经推送了 Finish。 |
Connected、数据报事件 |
忽略。 | 忽略。 | 忽略。 |
握手截止时间由第一个字节事件设置一次,分块陆续到达时不会刷新,因此它限制的是从第一个字节到地址解析完成的时间。之后由嗅探窗口或中继空闲截止时间接替。一个连上后从不发送字节的客户端根本不会产生任何运行时事件。对于这种情况,app 的 serve 循环(app/src/serve.rs → drive)在 is_established() 为 true 之前,用同一个 HANDSHAKE_TIMEOUT 限制每次等待运行时下一步的时间,超时则以 inbound handshake timed out after 10s 失败。
客户端 codec
Section titled “客户端 codec”SsStream 运行在客户端运行时中,每个流一个 codec:
start生成上行 salt 并暂存,创建ChunkEncoder,用ADDR.write_slice把目标写入一个AddressCodec::MAX_LEN字节的栈缓冲区,并把它封装为第一个分块。它返回Handshake::Done,因此明文可以立即跟上,无需往返。STAGING_RESERVE覆盖 32 字节 salt、一个分块开销和最长地址。reply对于Done握手从不会被调用;如果被调用,它以shadowsocks: no handshake reply失败。seal把min(plain.len(), MAX_PAYLOAD)字节封装成一个分块,并返回取走的字节数;运行时会为剩余部分再次调用。运行时遇到空写入会提前返回,所以seal永远不会产生会被对端当作结束标记的空分块。open先等待key_len字节的响应 salt,构建下行ChunkDecoder,并把这些字节报告为Opened::Frame { consumed: salt_len, plain: 0..0 },即一个没有明文的帧。此后它把ChunkStep一一映射到Opened:NeedMore、Frame、End。finish不暂存任何内容:上行以传输层 EOF 结束。
request_is_a_salt_and_a_sealed_address_then_chunks 用 ChunkDecoder 解码 codec 的输出,检查地址和载荷分属不同分块,并检查 seal 对更大的输入只取 MAX_PAYLOAD。response_salt_is_an_empty_frame_and_chunks_open_in_place 输入一个服务端形态的响应:不完整的 salt 得到 NeedMore,salt 是一个空帧,然后是一个数据分块和一个结束分块。
不支持 UDP
Section titled “不支持 UDP”aead.rs 模块的注释描述了 SIP004 的 UDP 数据包格式,但模块中没有任何代码实现它:
ShadowsocksCore的type TransportAddr = (),并忽略所有数据报事件。- 入站总是接在普通 TCP 监听器上(
InboundTransport::Tcp),reject_stream会拒绝任何 network 不是tcp或 security 不是none的[inbound.stream]。 - app 的出站是
ProxyClient<SS_BUF, SsStream, NoUdp>,路由到它的 UDP 流会以shadowsocks carries no datagrams失败。katana 的出站行为相同。
app/src/inbound/mod.rs:以2022-开头的method交给 Shadowsocks 2022;其他值必须通过Method::from_name,否则配置以inbound <tag>: unknown shadowsocks method "<name>"失败。入站的password和users组成ShadowsocksServerConfig<()>,一次性解析为StreamProtocol::Shadowsocks(Arc<Resolved<()>>)。app/src/serve.rs:每个接受的连接都用ShadowsocksCore::new(resolved.clone(), sniff, source)运行drive::<{ ShadowsocksCore::<()>::BUF_SIZE }, _, _>。app/src/outbound/mod.rs:出站用evp_bytes_to_key派生一次主密钥,并为每个流构建一个SsStream。与入站不同,出站经过build_transport,因此可以运行在 TLS、WebSocket 或 gRPC 之上。客户端运行时缓冲区为SS_BUF= 20 KiB。
src/inbound.rs→build_shadowsocks根据面板的用户列表构建同样的Resolved:每个用户的面板uuid就是它的密码,流量 email 作为email,载荷是一个UserTag。单密码回退为空且不使用;没有用户的节点会以shadowsocks node requires at least one user被拒绝。加密方法名先按 Shadowsocks 2022 方法尝试,再用Method::from_name尝试;其他值以node requests kernel-unsupported feature: shadowsocks cipher "<name>"失败。src/serve.rs在按ShadowsocksCore::<UserTag>::BUF_SIZE设定大小的运行时上运行ShadowsocksCore::<UserTag>。src/outbound/mod.rs与 app 一样,在 20 KiB 的客户端缓冲区上为每个流构建一个SsStream。
| 不变量 | 由谁保证 | 由哪些测试固定 |
|---|---|---|
| 线上的任何字节都不会被解密两次。 | ChunkDecoder 从副本解开长度,存入 pending_len,只有整个分块到齐才解开分块体。 |
chunk_encoder_and_decoder_agree_and_never_decrypt_twice(protocols/tests/unit/ss_legacy/aead.rs) |
| 尝试一个用户不会改变任何状态。 | Session::matches_first_chunk 只取 &self,在副本上操作并使用自己的全零 nonce;在匹配到用户之前 core 不消耗任何字节。 |
matches_first_chunk_selects_key(aead.rs);the_matching_user_is_picked_by_trial_decryption_and_the_address_parsed(protocols/tests/unit/ss_legacy/core.rs) |
| 未知密钥永远不会打开流。 | Salt 状态在任何 Effect::Open 之前就返回 PermissionDenied。 |
an_unknown_password_is_refused(core.rs) |
封装的分块都不超过 MAX_PAYLOAD。 |
输入更大时 seal_into 返回 None、seal_to_vec 返回错误;core 用 chunks(MAX_PAYLOAD) 切分;SsStream::seal 最多取 MAX_PAYLOAD。 |
chunk_encoder_and_decoder_agree_and_never_decrypt_twice、request_is_a_salt_and_a_sealed_address_then_chunks(codec.rs)、the_response_is_a_salt_then_sealed_chunks_until_the_target_closes(core.rs) |
| staging 区不足时不会浪费 nonce。 | seal_into 在封装前检查大小和剩余空间,返回 None 时 nonce 保持不变。 |
chunk_encoder_and_decoder_agree_and_never_decrypt_twice 检查了在 16 字节 staging 区上的拒绝;没有测试检查之后的 nonce。 |
| 地址可以跨越多个分块。 | 明文累积在 held 中,每个分块之后重试解析。 |
an_address_split_across_chunks_is_reassembled(core.rs) |
| 每个方向有自己的子密钥和 nonce 序列。 | 各自的 salt、各自的 Session,计数器都从零开始。 |
由 ss_new_server_vs_new_client_tcp(protocols/tests/pipeline/shadowsocks.rs)针对全部四种方法端到端覆盖 |
| 响应 salt 最先发出且只发一次,目标静默时也一样。 | start_response 在第一个 Outbound 和 OutboundEof 时运行,由 encoder.is_some() 防止重复。 |
a_silent_target_still_gets_a_salt_before_eof、the_response_is_a_salt_then_sealed_chunks_until_the_target_closes(core.rs) |
| 不写出终止分块;以 EOF 结束一个方向。 | SsStream::finish 不暂存任何内容;core 的 OutboundEof 只暂存尚未发送的 salt。 |
the_response_is_a_salt_then_sealed_chunks_until_the_target_closes(core.rs) |
| 从对端读到零长度即结束该方向。 | ChunkDecoder::open 在 18 字节长度头之后返回 End。 |
an_empty_chunk_ends_the_uplink(core.rs)、response_salt_is_an_empty_frame_and_chunks_open_in_place(codec.rs) |
| 嗅探过的字节作为一个整体、先于其余数据到达出站。 | SniffPrefix 加 Effect::ForwardHeld;剩余部分合并进 held。 |
sniffing_an_ip_target_waits_for_a_recognisable_prefix(core.rs) |
流的用户就是匹配到的条目,以 email 标识。 |
matched.user 由 UserEntry 构建。 |
the_matching_user_is_picked_by_trial_decryption_and_the_address_parsed(core.rs) |
失败路径与取消
Section titled “失败路径与取消”core 返回的任何错误都会结束连接。运行时以 RuntimeError::Core 停止,其文本是在 core 的消息前加上前缀 proxy core: 。app 的 serve 循环把它转换成 kind 为 Other 的 io::Error,并连同协议名和客户端地址以 debug 级别记录,因此下表中的错误 kind 只在 core 边界上可见。
| 位置 | 条件 | 结果 |
|---|---|---|
Salt |
没有任何用户的密钥能验证第一个长度。 | PermissionDenied:shadowsocks: no matching user |
| 任意分块 | tag 验证失败。 | InvalidData:shadowsocks AEAD open failed |
Address |
结束标记在地址之前到达。 | InvalidData:shadowsocks: stream ended before the address |
Address |
未知地址类型,或无效域名。 | 来自地址编解码器的 InvalidData,例如 unknown address type: 5 |
Salt、Address |
客户端关闭。 | Effect::Finish;没有打开过出站。 |
Salt、Address |
第一个字节之后超过 HANDSHAKE_TIMEOUT。 |
TimedOut:client did not complete its request in time |
Relay 之前(app serve 循环) |
在 HANDSHAKE_TIMEOUT 内运行时没有任何进展,例如客户端从不发送字节。 |
TimedOut:inbound handshake timed out after 10s |
Relay |
结束标记之后仍有字节到达。 | InvalidData:shadowsocks: no session |
Relay |
出站连接失败或出错。 | debug 日志 shadowsocks: outbound gone: …,然后 ShutdownTransport 和 Finish;已暂存的字节仍会发完。 |
| 运行时 | 大于 BUF_SIZE 的分块填满读缓冲区。 |
RuntimeError::FrameTooLarge:protocol frame exceeds the read buffer |
| Core | staging 区剩余空间小于 STAGING_RESERVE。 |
staging room below the core's declared reserve;这是 core 的 bug,不是对端错误。 |
SsStream |
目标无法编码。 | InvalidInput:shadowsocks: address |
SsStream |
在 start 之前调用 seal。 |
shadowsocks: sealed before start |
SsStream |
staging 区剩余空间小于其预留值。 | shadowsocks: staging room below the declared reserve |
| 客户端运行时 | 下行分块大于客户端缓冲区。 | InvalidData:upstream frame larger than the client runtime's buffer |
客户端运行时把 SsStream 的每个错误都包装为 InvalidData,因此 shadowsocks: address 的 InvalidInput kind 不会传到调用方。
取消不需要 core 做任何事。它不持有任务、锁或 channel;丢弃运行时就会丢弃 core 及其会话和缓冲区。
| 常量 | 值 | 位置 |
|---|---|---|
TAG_LEN |
16 字节 | aead.rs,所有方法 |
LEN_BYTES(私有) |
2 字节 | aead.rs |
FIRST_CHUNK_LEN |
18 字节 | aead.rs,试解密的长度头 |
CHUNK_OVERHEAD |
34 字节 | aead.rs |
MAX_PAYLOAD |
0x3FFF = 16 383 字节 |
aead.rs,编码器在一个分块中放入的最大字节数 |
ShadowsocksCore::BUF_SIZE |
20 * 1024 = 20 480 字节 |
core.rs,服务端运行时的缓冲区 |
ShadowsocksCore STAGING_RESERVE |
32 + 2 × 34 + 28 = 128 字节 | core.rs |
SsStream STAGING_RESERVE |
32 + 34 + 259 + 61 = 386 字节 | codec.rs |
SS_BUF |
20 * 1024 字节 |
app 和 katana 出站,客户端运行时的缓冲区 |
AddressCodec::MAX_LEN |
259 字节 | helpers/address.rs |
HANDSHAKE_TIMEOUT |
10 秒 | protocols/src/core/mod.rs |
SNIFF_TIMEOUT |
300 毫秒 | protocols/src/sniff/mod.rs |
SNIFF_LIMIT |
4 KiB | protocols/src/sniff/mod.rs |
RELAY_IDLE_TIMEOUT |
300 秒 | protocols/src/core/mod.rs |
一个满载分块是 34 + 16 383 = 16 417 字节,前面再加 32 字节 salt,仍能放进 20 480 字节的缓冲区并留有余量。声明的分块长度超出缓冲区容量的对端会被拦下:服务端由 RuntimeError::FrameTooLarge 处理,客户端由 upstream frame larger than the client runtime's buffer 处理。
修改这部分代码
Section titled “修改这部分代码”-
新增加密方法。 添加一个
Method变体及其key_len、nonce_len和from_name名称,添加一个Cipher变体并在Cipher::new、zero_nonce、seal和open中补上对应分支,再扩展protocols/tests/unit/ss_legacy/aead.rs中的METHODS以及ss_new_server_vs_new_client_tcp中的方法列表。两个STAGING_RESERVE值都假定 salt 最多 32 字节;更长的密钥需要同时调大两者。 -
修改解码器。 保持“长度头从副本解开、长度记下来”这一规则。任何随分块推进的额外状态都应放在
pending_len旁边,并且chunk_encoder_and_decoder_agree_and_never_decrypt_twice在不完整呈现的情况下必须继续通过。 -
修改分块大小。
MAX_PAYLOAD、BUF_SIZE、SS_BUF和 core 的STAGING_RESERVE(每次出站读取两个分块)相互关联。把BUF_SIZE调到超过两个MAX_PAYLOAD分块时,预留值也要一起调大。 -
修改用户 API。 katana 逐字段构造
ShadowsocksServerConfig和ShadowsocksUser并调用Resolved::new,因此改动这些公开类型对etemenanki-protocols来说是 SemVer 变更。
| 测试 | 文件 | 固定的行为 |
|---|---|---|
evp_key_known_answer |
protocols/tests/unit/ss_legacy/aead.rs |
EVP_BytesToKey("test") 等于 MD5("test");32 字节密钥在此基础上延伸。 |
method_lengths |
同上 | 密钥和 nonce 长度。 |
chunk_stream_roundtrip |
同上 | 在 duplex 上从 EncryptWriter 到 DecryptReader,全部四种方法,正常 EOF。 |
matches_first_chunk_selects_key |
同上 | 只有封装时使用的密钥能验证第一个分块。 |
chunk_encoder_and_decoder_agree_and_never_decrypt_twice |
同上 | 不完整呈现、Data 范围、End { consumed: 18 }、大小和空间不足时的拒绝。 |
request_is_a_salt_and_a_sealed_address_then_chunks |
protocols/tests/unit/ss_legacy/codec.rs |
客户端请求布局;seal 最多取 MAX_PAYLOAD。 |
response_salt_is_an_empty_frame_and_chunks_open_in_place |
同上 | 不完整的 salt、salt 作为空帧、数据、结束。 |
the_matching_user_is_picked_by_trial_decryption_and_the_address_parsed |
protocols/tests/unit/ss_legacy/core.rs |
salt 之后只有 10 字节时不消耗任何字节;选中第二个用户 bob;先 Open 后 Forward;进入已建立状态。 |
an_unknown_password_is_refused |
同上 | PermissionDenied。 |
an_address_split_across_chunks_is_reassembled |
同上 | 地址跨越两个分块。 |
sniffing_an_ip_target_waits_for_a_recognisable_prefix |
同上 | 设置 SNIFF_TIMEOUT;嗅探出的域名;一个 ForwardHeld;中继空闲截止时间。 |
the_response_is_a_salt_then_sealed_chunks_until_the_target_closes |
同上 | MAX_PAYLOAD + 10 字节的响应在 salt 之后能解码还原;在 OutboundEof 时执行 ShutdownTransport 且没有终止分块。 |
a_silent_target_still_gets_a_salt_before_eof |
同上 | 恰好暂存 key_len 字节。 |
an_empty_chunk_ends_the_uplink |
同上 | 零长度导致 Effect::Shutdown。 |
ss_new_server_vs_new_client_tcp |
protocols/tests/pipeline/shadowsocks.rs |
对每种方法,通过真实运行时和双用户服务端回显 70 000 字节,然后正常关闭。 |
运行方式:
cargo test -p etemenanki-protocols --lib ss_legacycargo test -p etemenanki-protocols --test pipeline shadowsocks第二条命令还会运行位于同一文件中的 Shadowsocks 2022 pipeline 测试。测试工具(CoreHarness::feed、serve_runtime、client)的工作方式见测试。