Skip to content

Shadowsocks AEAD

Source files: 21 · checked against 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

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.

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 a ProxyCoreEncode (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).

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
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 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.

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"]

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.

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.

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.

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.

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.

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.

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.

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 wire stays ciphertext.
  • It remembers the opened length in pending_len and 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.

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.

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

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.

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)

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:

  1. A ChunkDecoder is created from a fresh Session for that key and salt. It opens the first chunk again, this time for real, starting at nonce 0.
  2. matched records the key (for the response subkey later) and a NetworkUser whose authorization is UserAuthorization::UsernamePassword { username: email, password: "" } and whose user_data is the entry’s payload. The password itself never leaves the key table.
  3. 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.

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.

on_address builds the Flow (Flow::new(dest, user, source)) and decides:

  • No sniffing (sniff is false, or the destination is a domain, per worth_sniffing): the leading payload becomes held, and open pushes Effect::Open, then Effect::ForwardHeld over the held bytes if there are any, and enters Phase::Relay.
  • Sniffing an IP target: the leading payload goes into prefix. If the sniffer wants more, the core enters State::Sniff and Phase::Sniff, which arms SNIFF_TIMEOUT. If the sniff budget ran out with payload left over, the collected prefix and the rest are joined into held so one ForwardHeld carries both. Otherwise the flow opens with flow.sniffed set 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.

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.

SsStream runs inside the client runtime, one codec per flow:

  • start draws the uplink salt, stages it, creates the ChunkEncoder, writes the destination with ADDR.write_slice into a stack buffer of AddressCodec::MAX_LEN bytes and seals it as the first chunk. It returns Handshake::Done, so plaintext may follow at once, with no round trip. STAGING_RESERVE covers the 32-byte salt, one chunk overhead and the longest address.
  • reply is never called for a Done handshake; if it were, it fails with shadowsocks: no handshake reply.
  • seal seals min(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, so seal never produces an empty chunk that the peer would read as the end marker.
  • open first waits for key_len bytes of response salt, builds the downlink ChunkDecoder and reports them as Opened::Frame { consumed: salt_len, plain: 0..0 }, a frame with no plaintext. After that it maps ChunkStep one to one onto Opened: NeedMore, Frame, End.
  • finish stages 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.

The aead.rs module notes describe SIP004’s UDP packet format, but nothing in the module implements it:

  • ShadowsocksCore has type TransportAddr = () and ignores every datagram event.
  • The inbound is always wired to a plain TCP listener (InboundTransport::Tcp), and reject_stream refuses any [inbound.stream] with a network other than tcp or a security other than none.
  • The app’s outbound is ProxyClient<SS_BUF, SsStream, NoUdp>, and a UDP flow routed to it fails with shadowsocks carries no datagrams. katana’s outbound behaves the same way.
  • app/src/inbound/mod.rs: a method starting with 2022- goes to Shadowsocks 2022; anything else must pass Method::from_name, or the config fails with inbound <tag>: unknown shadowsocks method "<name>". The inbound’s password and users become a ShadowsocksServerConfig<()>, resolved once into StreamProtocol::Shadowsocks(Arc<Resolved<()>>).
  • app/src/serve.rs: each accepted connection runs drive::<{ ShadowsocksCore::<()>::BUF_SIZE }, _, _> with ShadowsocksCore::new(resolved.clone(), sniff, source).
  • app/src/outbound/mod.rs: the outbound derives the master key once with evp_bytes_to_key and builds an SsStream per flow. Unlike the inbound, the outbound goes through build_transport, so it can run over TLS, WebSocket or gRPC. The client runtime buffer is SS_BUF = 20 KiB.
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)

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.

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.

  1. Adding a cipher. Add a Method variant, its key_len, nonce_len and from_name names, a Cipher variant with its arms in Cipher::new, zero_nonce, seal and open, and extend METHODS in protocols/tests/unit/ss_legacy/aead.rs and the method list in ss_new_server_vs_new_client_tcp. Both STAGING_RESERVE values assume a salt of at most 32 bytes; a longer key needs both raised.

  2. 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, and chunk_encoder_and_decoder_agree_and_never_decrypt_twice must keep passing with partial presentations.

  3. Changing chunk sizes. MAX_PAYLOAD, BUF_SIZE, SS_BUF and the core’s STAGING_RESERVE (two chunks per outbound read) are tied together. Raising BUF_SIZE past two MAX_PAYLOAD chunks means raising the reserve too.

  4. Changing the users API. katana constructs ShadowsocksServerConfig and ShadowsocksUser field by field and calls Resolved::new, so a change to these public types is a SemVer change for etemenanki-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:

Terminal window
cargo test -p etemenanki-protocols --lib ss_legacy
cargo test -p etemenanki-protocols --test pipeline shadowsocks

The 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.