跳转到内容

Shadowsocks AEAD

源码文件:21 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/ss_legacy/mod.rs
  • Etemenanki/protocols/src/ss_legacy/aead.rs
  • Etemenanki/protocols/src/ss_legacy/users.rs
  • Etemenanki/protocols/src/ss_legacy/core.rs
  • Etemenanki/protocols/src/ss_legacy/codec.rs
  • Etemenanki/protocols/src/ss_legacy/protocol.rs
  • Etemenanki/protocols/src/helpers/crypto.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/protocols/tests/unit/ss_legacy/aead.rs
  • Etemenanki/protocols/tests/unit/ss_legacy/codec.rs
  • Etemenanki/protocols/tests/unit/ss_legacy/core.rs
  • Etemenanki/protocols/tests/pipeline/shadowsocks.rs
  • katana/src/inbound.rs
  • katana/src/outbound/mod.rs
  • katana/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 不发送终止分块。
字段 长度(字节) 含义
加密长度 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 字节。服务端不假定地址会在一个分块内到齐:它会持续累积明文,直到地址能够解析。

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 长度。

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,既不改动分块也不改动任何计数器,因此可以对每个用户的密钥逐一运行而没有副作用。

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)。切分输入是调用方的事,编码器从不切分。

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 字节。

服务端运行时每次调用都把整个未解析区域交给 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>>;每条连接只读取它。

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)。

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)。

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。第一个能验证加密长度的用户胜出:

  1. 用该密钥和 salt 新建一个 Session,由它创建 ChunkDecoder。解码器从 nonce 0 开始,这次真正地再次解开第一个分块。
  2. matched 记录该密钥(供之后派生响应子密钥)和一个 NetworkUser:其 authorization 为 UserAuthorization::UsernamePassword { username: email, password: "" },user_data 为该条目的载荷。密码本身从不离开密钥表。
  3. 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。

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 失败。

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 是一个空帧,然后是一个数据分块和一个结束分块。

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。
不变量 由谁保证 由哪些测试固定
线上的任何字节都不会被解密两次。 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)

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 处理。

  1. 新增加密方法。 添加一个 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 字节;更长的密钥需要同时调大两者。

  2. 修改解码器。 保持“长度头从副本解开、长度记下来”这一规则。任何随分块推进的额外状态都应放在 pending_len 旁边,并且 chunk_encoder_and_decoder_agree_and_never_decrypt_twice 在不完整呈现的情况下必须继续通过。

  3. 修改分块大小。 MAX_PAYLOAD、BUF_SIZE、SS_BUF 和 core 的 STAGING_RESERVE(每次出站读取两个分块)相互关联。把 BUF_SIZE 调到超过两个 MAX_PAYLOAD 分块时,预留值也要一起调大。

  4. 修改用户 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_legacy
cargo test -p etemenanki-protocols --test pipeline shadowsocks

第二条命令还会运行位于同一文件中的 Shadowsocks 2022 pipeline 测试。测试工具(CoreHarness::feed、serve_runtime、client)的工作方式见测试。