Shadowsocks AEAD
Source files: 21 · checked against 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
This page describes the legacy Shadowsocks implementation in etemenanki-protocols: the SIP004 AEAD chunk stream, how keys and nonces are derived, how one port serves several users, and the two sans-I/O halves built on it, the server core ShadowsocksCore and the client codec SsStream. It is written for contributors who change protocols/src/ss_legacy/. Shadowsocks 2022 lives in a separate module and has its own page.
TCP only Neither half carries UDP.
Responsibilities
Section titled “Responsibilities”The ss_legacy module owns:
- the four AEAD ciphers and their key, salt and nonce sizes (
Method); - per-session subkeys and the little-endian nonce counter (
Session,NonceCounter); - sealing and opening the chunk stream, in place and without decrypting anything twice (
ChunkEncoder,ChunkDecoder); - turning a password list into a key table once per configuration (
Resolved); - the server side as a
ProxyCoreDecode(ShadowsocksCore) and the client side as aProxyCoreEncode(SsStream).
It leaves out, on purpose:
| Not here | Where it is instead |
|---|---|
Stream ciphers, none, plain |
Not supported anywhere; Method::from_name returns None, and both the app and katana reject the configuration. |
| Shadowsocks 2022 (SIP022) | protocols/src/ss_2022/, see Shadowsocks 2022. |
| UDP relay | Not implemented; see No UDP. |
| Sockets, timers, buffers | The per-connection runtimes; see server runtime and client runtime. |
| The SOCKS-style address codec | protocols/src/helpers/address.rs → AddressCodec, shared with other protocols; see foundations. |
| Sniffing | protocols/src/sniff/, see sniffing. |
| File | Contents |
|---|---|
protocols/src/ss_legacy/mod.rs |
Re-exports Method, SsStream, ShadowsocksCore, Resolved, ShadowsocksServerConfig, ShadowsocksUser. |
protocols/src/ss_legacy/aead.rs |
Method, Session, NonceCounter, ChunkEncoder, ChunkDecoder, ChunkStep, the constants, and the async adapters EncryptWriter / DecryptReader. |
protocols/src/ss_legacy/users.rs |
ShadowsocksUser, ShadowsocksServerConfig<T>, UserEntry<T>, Resolved<T>. |
protocols/src/ss_legacy/core.rs |
ShadowsocksCore<T>, the server state machine. |
protocols/src/ss_legacy/codec.rs |
SsStream, the client codec. |
protocols/src/ss_legacy/protocol.rs |
ADDR, the address codec (AddressCodec::SOCKS). |
Wire format
Section titled “Wire format”A Shadowsocks TCP connection carries two independent encrypted streams, one per direction. Each stream starts with a random salt and continues with AEAD chunks sealed under a subkey derived from that salt. There is no handshake and no reply header: the client starts sending the moment it connects, and the server’s first bytes are its own salt.
| Field | Size (bytes) | Meaning |
|---|---|---|
| Salt | key_len: 16 or 32 |
Random; the HKDF salt for the uplink subkey. |
| Chunk 0 | CHUNK_OVERHEAD + address length |
Its plaintext is the target address. SsStream seals the address alone in this chunk. |
| Chunk 1 … n | CHUNK_OVERHEAD + payload length |
Payload, at most MAX_PAYLOAD bytes each. |
| (end) | none | Transport EOF. SsStream sends no terminator chunk. |
| Field | Size (bytes) | Meaning |
|---|---|---|
| Salt | key_len: 16 or 32 |
A fresh random salt chosen by the server; the HKDF salt for the downlink subkey. Sent ahead of the first downlink byte, or at the target’s EOF if the target sent nothing. |
| Chunk 0 … n | CHUNK_OVERHEAD + payload length |
Payload from the target, at most MAX_PAYLOAD bytes each. |
| (end) | none | Transport EOF. ShadowsocksCore sends no terminator chunk. |
One chunk
Section titled “One chunk”| Field | Size (bytes) | Meaning |
|---|---|---|
| Sealed length | 2 | The payload length as a big-endian u16, encrypted. |
| Length tag | 16 (TAG_LEN) |
AEAD tag of the length. |
| Sealed payload | length | The payload, encrypted. |
| Payload tag | 16 (TAG_LEN) |
AEAD tag of the payload. |
A chunk therefore costs CHUNK_OVERHEAD = 2 + 16 + 16 = 34 bytes beyond its payload. The first 18 bytes of a stream after the salt (FIRST_CHUNK_LEN, the sealed length and its tag) are what the server trial-decrypts to identify the user. No associated data is used for either seal.
A sealed length of zero is the end-of-stream marker on the read side: ChunkDecoder returns ChunkStep::End after consuming only those 18 bytes. Neither ShadowsocksCore nor SsStream ever writes one; both end their direction with the transport’s EOF. ChunkEncoder itself can seal an empty chunk (the unit tests use one as the end marker), so the callers are what keep it off the wire.
The address
Section titled “The address”The plaintext of the uplink starts with the target address in SOCKS form (protocols/src/ss_legacy/protocol.rs → ADDR is AddressCodec::SOCKS); everything after it is payload.
| Field | Size (bytes) | Meaning |
|---|---|---|
| Address type | 1 | 0x01 IPv4, 0x03 domain, 0x04 IPv6. Any other value is an error. |
| Address | 4, 1 + n, or 16 | For a domain, one length byte and then n bytes of name. |
| Port | 2 | Big-endian. |
The longest encoded address is AddressCodec::MAX_LEN = 1 + 1 + 255 + 2 = 259 bytes. The server does not assume the address arrives in one chunk: it accumulates plaintext until the address parses.
Keys, salts and nonces
Section titled “Keys, salts and nonces”flowchart LR pw["password"] --> evp["EVP_BytesToKey (MD5)"] evp --> master["master key, key_len bytes"] salt["salt, key_len bytes"] --> hkdf["HKDF-SHA1, info ss-subkey"] master --> hkdf hkdf --> sub["subkey, key_len bytes"] sub --> session["Session (AEAD cipher)"] session --> enc["ChunkEncoder + NonceCounter"] session --> dec["ChunkDecoder + NonceCounter"]
Master key
Section titled “Master key”protocols/src/helpers/crypto.rs → evp_bytes_to_key is OpenSSL’s EVP_BytesToKey with MD5, one iteration and no salt: D1 = MD5(password), Di = MD5(Di-1 ‖ password), concatenated and truncated to key_len. For a 16-byte key the result is MD5(password), which evp_key_known_answer pins with the known answer for "test".
pub fn evp_bytes_to_key(password: &[u8], key_len: usize) -> Vec<u8>The master key is derived once per configuration, never per connection: by Resolved::new for every server user, and by the outbound builder for a client.
Session subkey
Section titled “Session subkey”hkdf_sha1_ss_subkey runs HKDF-SHA1 with the salt as HKDF salt, the master key as input keying material and the fixed info string ss-subkey, producing key_len bytes. Each Session::new derives one subkey and instantiates the AEAD with it, so every direction of every connection has its own key.
pub fn hkdf_sha1_ss_subkey(master_key: &[u8], salt: &[u8], out: &mut [u8])Salts come from random_salt, which fills key_len bytes from rand::rng(). The client draws the uplink salt in SsStream::start; the server draws the downlink salt when it first responds.
Nonces
Section titled “Nonces”Each direction has one NonceCounter, starting at all zeroes and incremented as a little-endian integer (increment_le) after every AEAD operation. Sealing or opening a chunk takes two operations, so chunk i of a direction uses nonce 2i for its length and 2i + 1 for its payload. The two directions never share a counter or a subkey, because each has its own salt and its own Session.
Methods
Section titled “Methods”Method |
Names from_name accepts |
Key and 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 lower-cases its argument before matching, so AEAD_CHACHA20_POLY1305 is accepted; it does not trim whitespace. The salt is always as long as the key: key_len serves as both. method_lengths pins some of the sizes: the AES key lengths and the ChaCha20 and XChaCha20 nonce lengths.
Key types
Section titled “Key types”Method and Session
Section titled “Method and 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 is private: one boxed aes-gcm or chacha20poly1305 instance per variant. seal_in_place and open_in_place work on detached tags and leave the nonce to the caller. The width of NonceCounter follows the cipher (zero_nonce), and a mismatched pair fails with shadowsocks AEAD nonce/cipher mismatch rather than panicking. A failed open is InvalidData with the text shadowsocks AEAD open failed.
matches_first_chunk is the trial-decryption primitive. It copies the two sealed length bytes, opens the copy under a fresh zero nonce, and reports whether the tag verified; a chunk shorter than 18 bytes reports false. It takes &self and touches neither the chunk nor any counter, so it can be run against every user’s key without side effects.
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<()>;}Both entry points seal exactly one chunk through the private seal_frame: write the big-endian length, seal it, advance the nonce, copy the payload, seal it, advance the nonce. seal_into writes straight into a runtime’s Staging area and returns None, with the nonce untouched, when plain exceeds MAX_PAYLOAD or out.room() is below CHUNK_OVERHEAD + plain.len(). seal_to_vec appends to a Vec and returns InvalidInput (shadowsocks chunk exceeds the payload limit) for an oversized payload. Callers split their input; the encoder never does.
ChunkDecoder and ChunkStep
Section titled “ChunkDecoder and 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 looks at the chunk at the front of wire. On Data, the payload has been decrypted in place, plain is its range inside wire, and consumed covers the whole chunk. The caller forwards plain by range and advances by consumed.
Nothing is decrypted twice
Section titled “Nothing is decrypted twice”A server runtime hands a core the whole unparsed region on every call and presents unconsumed bytes again, with more appended, on the next call (see server cores). If a decoder decrypted the length header in place and then returned NeedMore because the body had not arrived, the next call would see already-decrypted bytes and decrypt them again with the wrong nonce.
ChunkDecoder avoids that in two ways:
- It opens the length from a copy of the two sealed bytes, never in the wire buffer, so the header in
wirestays ciphertext. - It remembers the opened length in
pending_lenand advances the nonce once. On the next call it skips straight to the body check.
The payload is opened in place only once HEADER + len + TAG_LEN bytes are present, and pending_len is cleared in the same step. A partial chunk therefore costs one length open, however many times it is presented.
sequenceDiagram participant R as Runtime participant D as ChunkDecoder R->>D: open(header and first body bytes) D->>D: open length from a copy, nonce += 1, pending_len = Some(len) D-->>R: NeedMore R->>D: open(same bytes and more of the body) D->>D: pending_len is set, header skipped D-->>R: NeedMore R->>D: open(whole chunk) D->>D: open body in place, nonce += 1, pending_len = None D-->>R: Data consumed and plain range
chunk_encoder_and_decoder_agree_and_never_decrypt_twice pins this sequence for all four methods: the first 20 bytes of a 39-byte chunk (the header and two body bytes), then the first 30 bytes, then the whole wire, followed by a second chunk and the end marker.
The decoder does not compare the length against MAX_PAYLOAD; it accepts any declared length up to 0xFFFF. The runtime’s read buffer bounds it instead: a chunk that does not fit the buffer fails the connection (see Limits).
Users: ShadowsocksServerConfig, Resolved, UserEntry
Section titled “Users: 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 derives every master key once, at configuration time:
config.users |
Resolved::users |
|---|---|
| Empty | One entry: the key of config.password, an empty email, and password_data. |
| Not empty | One entry per user, in configuration order: the key of that user’s password, its email, and its payload. config.password is ignored. |
T is the per-user payload the application attaches: () in etemenanki-app, a user tag in katana. The core hands it on in the flow’s NetworkUser::user_data. A server shares one Arc<Resolved<T>> across all connections of an inbound; each connection only reads it.
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];}| Field | Role |
|---|---|
inner |
The shared key table. |
sniff, source |
Whether to sniff IP targets; the client address copied into the Flow. |
timing |
The one deadline, armed per phase (protocols/src/core/mod.rs → Timing). |
state |
The private State enum below. |
matched |
The chosen user’s master key (kept to derive the response subkey) and its NetworkUser. |
decoder |
The uplink ChunkDecoder, created when the user is matched. |
encoder |
The downlink ChunkEncoder, created with the response salt. |
held |
Plaintext accumulated until the address parses, then the payload that followed it. |
prefix |
The SniffPrefix collecting the first payload bytes of an IP target. |
flow |
The Flow built from the address, waiting for Effect::Open. |
The key is Single: one connection carries one flow to one outbound. is_established is true once Timing has reached the relay phase; the app’s serve loop uses it to release the handshake 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 is the master key (evp_bytes_to_key of the password), computed by the caller. Down is private: Salt while waiting for the response salt, then Chunks(ChunkDecoder).
EncryptWriter and DecryptReader
Section titled “EncryptWriter and 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>These wrap a Tokio stream whose salt has already been written or read. EncryptWriter seals each poll_write into one chunk of at most MAX_PAYLOAD bytes and keeps the sealed bytes in pending until they are flushed, so a chunk is always emitted contiguously. DecryptReader runs a Length / Payload(len) stage machine and treats EOF as clean only at a chunk boundary; anywhere else it is UnexpectedEof (truncated shadowsocks chunk). At this revision nothing outside the unit tests uses them: both server and client go through the sans-I/O halves.
Data flow
Section titled “Data flow”sequenceDiagram participant C as SsStream (client) participant S as ShadowsocksCore (server) participant O as Outbound C->>S: salt_c, then chunk 0 = sealed address Note over S: try each user on the first 18 bytes, build ChunkDecoder Note over S: open chunks until the address parses S->>O: Effect::Open (Flow with user and destination) C->>S: chunks 1..n (payload) S->>O: Effect::Forward (plaintext range, in place) O-->>S: Event::Outbound (reply bytes) S-->>C: salt_s, then sealed chunks O-->>S: Event::OutboundEof S-->>C: ShutdownTransport (no terminator chunk)
Multi-user resolution
Section titled “Multi-user resolution”In the Salt state the core waits, consuming nothing, until it holds key_len + FIRST_CHUNK_LEN bytes. It then walks Resolved::users in order, builds a Session from each user’s master key and the received salt, and calls matches_first_chunk. The first user whose key authenticates the sealed length wins:
- A
ChunkDecoderis created from a freshSessionfor that key and salt. It opens the first chunk again, this time for real, starting at nonce 0. matchedrecords the key (for the response subkey later) and aNetworkUserwhoseauthorizationisUserAuthorization::UsernamePassword { username: email, password: "" }and whoseuser_datais the entry’s payload. The password itself never leaves the key table.- The core consumes only the salt and moves to
Address; the 18 bytes it tested are opened again in the next step.
If no key authenticates, handle fails with PermissionDenied (shadowsocks: no matching user) and the runtime ends the connection.
Address accumulation
Section titled “Address accumulation”In the Address state the core opens chunks one after another from the front of the slice and appends each chunk’s plaintext to held. After every chunk it tries ADDR.read_slice(&self.held), wrapped in need_more: a truncated address (an UnexpectedEof from the parser) means “keep going”, any other parse error fails the connection. Once the address parses, the core takes the bytes after it as the leading payload and calls on_address, then returns how far it got. The rest of the slice comes back on the next call.
Because the parse is retried after every chunk and an address is at most AddressCodec::MAX_LEN bytes, held never grows beyond one chunk’s plaintext plus 258 bytes. an_address_split_across_chunks_is_reassembled seals the first four address bytes, the rest of the address and GET as three separate chunks and expects one Open for example.com followed by a Forward of GET.
Sniffing or opening
Section titled “Sniffing or opening”on_address builds the Flow (Flow::new(dest, user, source)) and decides:
- No sniffing (
sniffis false, or the destination is a domain, perworth_sniffing): the leading payload becomesheld, andopenpushesEffect::Open, thenEffect::ForwardHeldover the held bytes if there are any, and entersPhase::Relay. - Sniffing an IP target: the leading payload goes into
prefix. If the sniffer wants more, the core entersState::SniffandPhase::Sniff, which armsSNIFF_TIMEOUT. If the sniff budget ran out with payload left over, the collected prefix and the rest are joined intoheldso oneForwardHeldcarries both. Otherwise the flow opens withflow.sniffedset and the prefix held.
In State::Sniff, every further chunk’s plaintext is pushed into prefix. When the verdict is no longer More, the core opens the flow (Open, then ForwardHeld of the prefix) and forwards the part of that chunk the sniffer did not take with a plain Effect::Forward. sniffing_an_ip_target_waits_for_a_recognisable_prefix sends an HTTP request split across two chunks and expects the sniffed domain example.com, a ForwardHeld of 41 bytes and the relay idle deadline. The sniffer itself is described on sniffing.
held() returns held when it is non-empty and the sniff prefix otherwise, so a ForwardHeld resolves against whichever buffer the open used. Both are cleared at the start of the next byte event in Relay, which the runtime’s held-buffer pin rule makes safe.
In State::Relay every call opens as many whole chunks as the slice holds and pushes one Effect::Forward per non-empty payload, with ranges pointing into the slice where the plaintext now lies. There is no copy between the wire buffer and the outbound.
On the way back, Event::Outbound reaches on_outbound. start_response stages a fresh salt once and creates the downlink ChunkEncoder from the matched key and that salt; then the data is split with chunks(MAX_PAYLOAD) and each piece sealed with seal_into. STAGING_RESERVE covers the salt plus two chunk overheads, because the runtime delivers an outbound read of n bytes only with STAGING_RESERVE + n bytes of staging free, and one read of at most BUF_SIZE (20 480) bytes splits into at most two chunks.
State machine
Section titled “State machine”stateDiagram-v2 [*] --> Salt Salt --> Address: a user key authenticates the first chunk Salt --> [*]: no matching user or handshake timeout Address --> Relay: address parsed, open Address --> Sniff: sniffing on, IP address, sniffer wants more Address --> [*]: bad chunk, bad address or handshake timeout Sniff --> Relay: verdict, budget spent, deadline or EOF Salt --> Done: transport EOF Address --> Done: transport EOF Relay --> [*]: both halves closed, outbound gone or idle timeout Done --> [*]
How each event is handled:
| Event | Salt / Address |
Sniff |
Relay |
|---|---|---|---|
Transport |
Timing::touch (arms HANDSHAKE_TIMEOUT on the first byte), then parse as above. |
Push plaintext into the prefix; open on a verdict. An end chunk opens the flow and half-closes it. | Forward payloads. An end chunk calls Passthrough::on_transport_eof and drops the decoder. |
Outbound |
Consumed and discarded (no outbound exists yet). | Consumed and discarded. | Clear prefix and held, then seal toward the client. |
TransportEof |
Move to Done and push Effect::Finish; nothing was opened. |
Open with what was sniffed, then half-close the outbound. | on_transport_eof: Effect::Shutdown of the outbound; Finish once the outbound has ended too. |
OutboundEof |
Ignored. | Ignored. | Stage the response salt if none was sent, then on_outbound_eof: Effect::ShutdownTransport, and Finish if the client’s side has already ended. |
ConnectFailed, OutboundError |
Logged at debug. | Logged at debug. | Logged, then on_outbound_gone: ShutdownTransport and Finish. |
Deadline |
Expired::Handshake: fail with TimedOut. |
Expired::Sniff: open with what was collected. |
Expired::Idle: Timing has already pushed Finish. |
Connected, datagram events |
Ignored. | Ignored. | Ignored. |
The handshake deadline is armed once, by the first byte event, and not refreshed while chunks arrive, so it bounds the time from the first byte until the address parses. After that the sniff window or the relay idle deadline replaces it. A client that connects and never sends a byte produces no runtime event at all. For that case the app’s serve loop (app/src/serve.rs → drive) bounds every wait for the runtime’s next step by the same HANDSHAKE_TIMEOUT until is_established() is true, and fails with inbound handshake timed out after 10s.
The client codec
Section titled “The client codec”SsStream runs inside the client runtime, one codec per flow:
startdraws the uplink salt, stages it, creates theChunkEncoder, writes the destination withADDR.write_sliceinto a stack buffer ofAddressCodec::MAX_LENbytes and seals it as the first chunk. It returnsHandshake::Done, so plaintext may follow at once, with no round trip.STAGING_RESERVEcovers the 32-byte salt, one chunk overhead and the longest address.replyis never called for aDonehandshake; if it were, it fails withshadowsocks: no handshake reply.sealsealsmin(plain.len(), MAX_PAYLOAD)bytes as one chunk and returns how many it took; the runtime calls again for the rest. The runtime returns early on an empty write, sosealnever produces an empty chunk that the peer would read as the end marker.openfirst waits forkey_lenbytes of response salt, builds the downlinkChunkDecoderand reports them asOpened::Frame { consumed: salt_len, plain: 0..0 }, a frame with no plaintext. After that it mapsChunkStepone to one ontoOpened:NeedMore,Frame,End.finishstages nothing: the uplink ends with the transport’s EOF.
request_is_a_salt_and_a_sealed_address_then_chunks decodes the codec’s output with a ChunkDecoder and checks that the address and the payload are separate chunks and that seal takes MAX_PAYLOAD of a larger input. response_salt_is_an_empty_frame_and_chunks_open_in_place feeds a server-shaped response: a partial salt gives NeedMore, the salt is an empty frame, then a data chunk and an end chunk.
No UDP
Section titled “No UDP”The aead.rs module notes describe SIP004’s UDP packet format, but nothing in the module implements it:
ShadowsocksCorehastype TransportAddr = ()and ignores every datagram event.- The inbound is always wired to a plain TCP listener (
InboundTransport::Tcp), andreject_streamrefuses any[inbound.stream]with a network other thantcpor a security other thannone. - The app’s outbound is
ProxyClient<SS_BUF, SsStream, NoUdp>, and a UDP flow routed to it fails withshadowsocks carries no datagrams. katana’s outbound behaves the same way.
Wiring
Section titled “Wiring”app/src/inbound/mod.rs: amethodstarting with2022-goes to Shadowsocks 2022; anything else must passMethod::from_name, or the config fails withinbound <tag>: unknown shadowsocks method "<name>". The inbound’spasswordandusersbecome aShadowsocksServerConfig<()>, resolved once intoStreamProtocol::Shadowsocks(Arc<Resolved<()>>).app/src/serve.rs: each accepted connection runsdrive::<{ ShadowsocksCore::<()>::BUF_SIZE }, _, _>withShadowsocksCore::new(resolved.clone(), sniff, source).app/src/outbound/mod.rs: the outbound derives the master key once withevp_bytes_to_keyand builds anSsStreamper flow. Unlike the inbound, the outbound goes throughbuild_transport, so it can run over TLS, WebSocket or gRPC. The client runtime buffer isSS_BUF= 20 KiB.
src/inbound.rs→build_shadowsocksbuilds the sameResolvedfrom the panel’s user list: each user’s paneluuidis its password, its traffic email is theemail, and its payload is aUserTag. The single-password fallback is empty and unused, and a node with no users is refused withshadowsocks node requires at least one user. The cipher name is tried as a Shadowsocks 2022 method first, then withMethod::from_name; anything else fails withnode requests kernel-unsupported feature: shadowsocks cipher "<name>".src/serve.rsrunsShadowsocksCore::<UserTag>over a runtime sized byShadowsocksCore::<UserTag>::BUF_SIZE.src/outbound/mod.rsbuilds anSsStreamper flow over a 20 KiB client buffer, like the app.
Invariants
Section titled “Invariants”| Invariant | Enforced by | Pinned by |
|---|---|---|
| No wire byte is decrypted twice. | ChunkDecoder opens the length from a copy, stores it in pending_len, and opens the body only when the whole chunk is present. |
chunk_encoder_and_decoder_agree_and_never_decrypt_twice (protocols/tests/unit/ss_legacy/aead.rs) |
| Trying a user changes nothing. | Session::matches_first_chunk takes &self, works on a copy and uses its own zero nonce; the core consumes nothing until a user matched. |
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) |
| An unknown key never opens a flow. | The Salt state returns PermissionDenied before any Effect::Open. |
an_unknown_password_is_refused (core.rs) |
No sealed chunk exceeds MAX_PAYLOAD. |
seal_into returns None and seal_to_vec errors on larger input; the core splits with chunks(MAX_PAYLOAD); SsStream::seal takes at most 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) |
| A short staging area never costs a nonce. | seal_into checks the size and the room before sealing and leaves the nonce untouched on None. |
chunk_encoder_and_decoder_agree_and_never_decrypt_twice checks the refusal on a 16-byte staging area; no test checks the nonce afterwards. |
| The address may span chunks. | Plaintext accumulates in held and the parse is retried after every chunk. |
an_address_split_across_chunks_is_reassembled (core.rs) |
| Each direction has its own subkey and nonce sequence. | Separate salts, a separate Session each, counters starting at zero. |
Exercised end to end by ss_new_server_vs_new_client_tcp (protocols/tests/pipeline/shadowsocks.rs), for all four methods |
| The response salt comes first, exactly once, even for a silent target. | start_response runs on the first Outbound and on OutboundEof, guarded by 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) |
| No terminator chunk is written; EOF ends a direction. | SsStream::finish stages nothing; the core’s OutboundEof only stages a missing salt. |
the_response_is_a_salt_then_sealed_chunks_until_the_target_closes (core.rs) |
| A zero length read from the peer ends that direction. | ChunkDecoder::open returns End after the 18-byte header. |
an_empty_chunk_ends_the_uplink (core.rs), response_salt_is_an_empty_frame_and_chunks_open_in_place (codec.rs) |
| Sniffed bytes reach the outbound in one piece, ahead of the rest. | SniffPrefix plus Effect::ForwardHeld; leftovers are joined into held. |
sniffing_an_ip_target_waits_for_a_recognisable_prefix (core.rs) |
The flow’s user is the matched entry, labelled by email. |
matched.user built from the UserEntry. |
the_matching_user_is_picked_by_trial_decryption_and_the_address_parsed (core.rs) |
Failure paths and cancellation
Section titled “Failure paths and cancellation”Every error the core returns ends the connection. The runtime stops with RuntimeError::Core, whose text is the core’s message behind the prefix proxy core: . The app’s serve loop turns it into an io::Error of kind Other and logs it at debug level together with the protocol name and the client address, so the error kinds below are visible only at the core boundary.
| Where | Condition | Result |
|---|---|---|
Salt |
No user’s key authenticates the first length. | PermissionDenied: shadowsocks: no matching user |
| Any chunk | A tag does not verify. | InvalidData: shadowsocks AEAD open failed |
Address |
The end marker arrives before the address. | InvalidData: shadowsocks: stream ended before the address |
Address |
Unknown address type, or an invalid domain. | InvalidData from the address codec, for example unknown address type: 5 |
Salt, Address |
The client closes. | Effect::Finish; no outbound was opened. |
Salt, Address |
HANDSHAKE_TIMEOUT passes after the first byte. |
TimedOut: client did not complete its request in time |
Before Relay (app serve loop) |
No runtime step within HANDSHAKE_TIMEOUT, for example a client that never sends a byte. |
TimedOut: inbound handshake timed out after 10s |
Relay |
Bytes arrive after the end marker. | InvalidData: shadowsocks: no session |
Relay |
The outbound fails to connect or errors. | Debug log shadowsocks: outbound gone: …, then ShutdownTransport and Finish; staged bytes still drain. |
| Runtime | A chunk larger than BUF_SIZE fills the read buffer. |
RuntimeError::FrameTooLarge: protocol frame exceeds the read buffer |
| Core | Staging room below STAGING_RESERVE. |
staging room below the core's declared reserve; a core bug, not a peer error. |
SsStream |
The destination cannot be encoded. | InvalidInput: shadowsocks: address |
SsStream |
seal before start. |
shadowsocks: sealed before start |
SsStream |
Staging room below its reserve. | shadowsocks: staging room below the declared reserve |
| Client runtime | A downlink chunk larger than the client buffer. | InvalidData: upstream frame larger than the client runtime's buffer |
The client runtime wraps every SsStream error as InvalidData, so the InvalidInput kind of shadowsocks: address does not reach the caller.
Cancellation needs nothing from the core. It holds no tasks, locks or channels; dropping the runtime drops the core, its sessions and its buffers.
Limits
Section titled “Limits”| Constant | Value | Where |
|---|---|---|
TAG_LEN |
16 bytes | aead.rs, every method |
LEN_BYTES (private) |
2 bytes | aead.rs |
FIRST_CHUNK_LEN |
18 bytes | aead.rs, the trial-decrypted header |
CHUNK_OVERHEAD |
34 bytes | aead.rs |
MAX_PAYLOAD |
0x3FFF = 16 383 bytes |
aead.rs, the most the encoders put in one chunk |
ShadowsocksCore::BUF_SIZE |
20 * 1024 = 20 480 bytes |
core.rs, the server runtime’s buffers |
ShadowsocksCore STAGING_RESERVE |
32 + 2 × 34 + 28 = 128 bytes | core.rs |
SsStream STAGING_RESERVE |
32 + 34 + 259 + 61 = 386 bytes | codec.rs |
SS_BUF |
20 * 1024 bytes |
app and katana outbound, the client runtime’s buffers |
AddressCodec::MAX_LEN |
259 bytes | helpers/address.rs |
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
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 |
A full chunk is 34 + 16 383 = 16 417 bytes, and with a 32-byte salt ahead of it still fits the 20 480-byte buffer with room to spare. A peer that declares a longer chunk than the buffer can hold is stopped: on the server by RuntimeError::FrameTooLarge, on the client by upstream frame larger than the client runtime's buffer.
Changing this code
Section titled “Changing this code”-
Adding a cipher. Add a
Methodvariant, itskey_len,nonce_lenandfrom_namenames, aCiphervariant with its arms inCipher::new,zero_nonce,sealandopen, and extendMETHODSinprotocols/tests/unit/ss_legacy/aead.rsand the method list inss_new_server_vs_new_client_tcp. BothSTAGING_RESERVEvalues assume a salt of at most 32 bytes; a longer key needs both raised. -
Touching the decoder. Keep the rule that the header is opened from a copy and its length remembered. Any extra state that advances with a chunk belongs next to
pending_len, andchunk_encoder_and_decoder_agree_and_never_decrypt_twicemust keep passing with partial presentations. -
Changing chunk sizes.
MAX_PAYLOAD,BUF_SIZE,SS_BUFand the core’sSTAGING_RESERVE(two chunks per outbound read) are tied together. RaisingBUF_SIZEpast twoMAX_PAYLOADchunks means raising the reserve too. -
Changing the users API. katana constructs
ShadowsocksServerConfigandShadowsocksUserfield by field and callsResolved::new, so a change to these public types is a SemVer change foretemenanki-protocols.
| Test | File | What it pins |
|---|---|---|
evp_key_known_answer |
protocols/tests/unit/ss_legacy/aead.rs |
EVP_BytesToKey("test") equals MD5("test"); the 32-byte key extends it. |
method_lengths |
same | Key and nonce lengths. |
chunk_stream_roundtrip |
same | EncryptWriter to DecryptReader over a duplex, all four methods, clean EOF. |
matches_first_chunk_selects_key |
same | Only the sealing key authenticates the first chunk. |
chunk_encoder_and_decoder_agree_and_never_decrypt_twice |
same | Partial presentations, Data ranges, End { consumed: 18 }, size and room refusals. |
request_is_a_salt_and_a_sealed_address_then_chunks |
protocols/tests/unit/ss_legacy/codec.rs |
Client request layout; seal caps at MAX_PAYLOAD. |
response_salt_is_an_empty_frame_and_chunks_open_in_place |
same | Partial salt, salt as an empty frame, data, end. |
the_matching_user_is_picked_by_trial_decryption_and_the_address_parsed |
protocols/tests/unit/ss_legacy/core.rs |
Nothing consumed with only 10 bytes after the salt; the second user, bob, chosen; Open then Forward; established. |
an_unknown_password_is_refused |
same | PermissionDenied. |
an_address_split_across_chunks_is_reassembled |
same | Address across two chunks. |
sniffing_an_ip_target_waits_for_a_recognisable_prefix |
same | SNIFF_TIMEOUT armed; sniffed domain; one ForwardHeld; relay idle deadline. |
the_response_is_a_salt_then_sealed_chunks_until_the_target_closes |
same | A reply of MAX_PAYLOAD + 10 bytes decodes back after the salt; ShutdownTransport and no terminator at OutboundEof. |
a_silent_target_still_gets_a_salt_before_eof |
same | Exactly key_len bytes staged. |
an_empty_chunk_ends_the_uplink |
same | A zero length leads to Effect::Shutdown. |
ss_new_server_vs_new_client_tcp |
protocols/tests/pipeline/shadowsocks.rs |
70 000 bytes echoed through real runtimes with a two-user server, for every method, then a clean close. |
Run them with:
cargo test -p etemenanki-protocols --lib ss_legacycargo test -p etemenanki-protocols --test pipeline shadowsocksThe second command also runs the Shadowsocks 2022 pipeline tests, which live in the same file. How the harnesses work (CoreHarness::feed, serve_runtime, client) is covered on testing.