Skip to content

Shadowsocks 2022

Source files: 24 · checked against Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/ss_2022/mod.rs
  • Etemenanki/protocols/src/ss_2022/crypto.rs
  • Etemenanki/protocols/src/ss_2022/protocol.rs
  • Etemenanki/protocols/src/ss_2022/users.rs
  • Etemenanki/protocols/src/ss_2022/core.rs
  • Etemenanki/protocols/src/ss_2022/codec.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/helpers/crypto.rs
  • Etemenanki/protocols/src/helpers/address.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/core/harness.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/protocols/tests/unit/ss_2022/protocol.rs
  • Etemenanki/protocols/tests/unit/ss_2022/core.rs
  • Etemenanki/protocols/tests/unit/ss_2022/codec.rs
  • Etemenanki/protocols/tests/pipeline/shadowsocks.rs
  • katana/src/inbound.rs
  • katana/src/outbound/mod.rs
  • katana/src/serve.rs

The etemenanki_protocols::ss_2022 module implements Shadowsocks 2022 (SIP022) for TCP. It is a port of sing-shadowsocks/shadowaead_2022, reshaped into the workspace’s sans-I/O model: a server core, Ss2022Core, that the per-connection runtime drives, and a client codec, Ss2022Stream, that the client runtime drives. Both share one set of wire primitives: BLAKE3 subkeys, a length-prefixed AEAD chunk stream, and the AES block cipher that wraps the Extended Identity Header (EIH) for multi-user servers.

This page is for contributors who change that module or the code that builds it. It goes down to the byte layout of each header, the state machines on both sides, and the tests that pin each rule. The user-facing configuration is in the Shadowsocks guide page; the legacy AEAD family (SIP004) has its own developer page.

TCP only The module has no datagram path. The app’s Outbound::Ss2022 answers every UDP flow with shadowsocks-2022 carries no datagrams (io::ErrorKind::Unsupported).

Concern Where What it does
Methods and key sizes crypto.rs → Method Parses the three 2022-blake3-* names and reports the key (and salt) length.
Key derivation crypto.rs → session_key, identity_subkey, eih_hash, fold_key BLAKE3 session and identity subkeys, the 16-byte identity hash, SHA-256 folding of over-long PSKs.
AEAD chunk stream crypto.rs → StreamAead, ChunkWriter, ChunkReader, RecordDecoder Seals and opens chunks under a little-endian nonce counter; opens length-prefixed records in place.
EIH block cipher crypto.rs → BlockCipher One raw AES block (ECB), AES-128 or AES-256 by key length.
Timestamp window crypto.rs → check_timestamp_at Rejects a header timestamp more than 30 seconds from the local clock.
Header framing protocol.rs Builds and parses the request and response headers; wraps and unwraps EIH blocks.
Users users.rs → Ss2022User, Ss2022ServerConfig, Validator PSK decoding and normalisation, and the identity-hash table for multi-user servers.
Server core.rs → Ss2022Core The sans-I/O server: parses the request, opens the flow, relays records, writes the salt-bound response.
Client codec.rs → Ss2022Stream The sans-I/O client codec: writes the request (with an optional identity chain), seals records, checks the response’s echoed salt.

What the module leaves to others:

  • I/O, buffers and back-pressure belong to the runtimes in etemenanki-concepts (ProxyServerRuntime, ProxyClientRuntime). The core and the codec only see byte slices and a Staging area.
  • Dialing and routing belong to the app’s connector. The core emits Effect::Open with a Flow, and the codec is told its destination when it is built.
  • Configuration parsing belongs to app/src/inbound/mod.rs and app/src/outbound/mod.rs, which pick the 2022 family whenever method starts with 2022-. katana builds the same types from panel data in its own inbound and outbound builders.
pub enum Method {
Blake3Aes128Gcm,
Blake3Aes256Gcm,
Blake3ChaCha20Poly1305,
}
impl Method {
pub fn from_name(name: &str) -> Option<Self>;
pub fn name(self) -> &'static str;
pub fn key_len(self) -> usize;
}
method Method variant key_len() (PSK and salt) Chunk AEAD EIH block cipher Multi-user server
2022-blake3-aes-128-gcm Blake3Aes128Gcm 16 AES-128-GCM AES-128 Yes
2022-blake3-aes-256-gcm Blake3Aes256Gcm 32 AES-256-GCM AES-256 Yes
2022-blake3-chacha20-poly1305 Blake3ChaCha20Poly1305 32 ChaCha20-Poly1305 none on the server No

Method::from_name matches the canonical names exactly, so 2022-BLAKE3-AES-128-GCM is refused. Because the app dispatches on the 2022- prefix first, a misspelt 2022 name fails with inbound <tag>: unknown shadowsocks-2022 method "<name>" (or outbound <tag>: …) instead of falling through to the legacy family. katana does not look at the prefix: it tries the exact 2022 names, then the legacy names, and refuses anything else.

The key length doubles as the salt length: every salt this module writes or reads is key_len() bytes.

The AEAD constants live next to the method:

Constant Value Meaning
TAG_SIZE 16 AEAD tag length, for GCM and Poly1305 alike.
MAX_PACKET_SIZE 0xFFFF Largest plaintext one chunk carries (the length prefix is a u16).
RECORD_OVERHEAD 2 + TAG_SIZE + TAG_SIZE = 34 Bytes one length-prefixed record adds beyond its data.
SESSION_SUBKEY_CONTEXT "shadowsocks 2022 session subkey" BLAKE3 derive_key context for session subkeys.
IDENTITY_SUBKEY_CONTEXT "shadowsocks 2022 identity subkey" BLAKE3 derive_key context for EIH subkeys.

A PSK enters the module as base64 text from the configuration and leaves it as exactly key_len() bytes.

pub fn decode_psk(password: &str) -> io::Result<Vec<u8>>;
pub fn normalise_psk(method: Method, psk: Vec<u8>) -> io::Result<Vec<u8>>;
pub fn fold_key(key: &[u8], key_len: usize) -> Vec<u8>;
  • decode_psk trims surrounding whitespace and decodes with the standard base64 alphabet. A decoding failure becomes decode PSK: <base64 error> (InvalidInput).
  • normalise_psk keeps a PSK of exactly key_len() bytes, folds a longer one with fold_key, and refuses a shorter one with shadowsocks-2022: PSK too short (<len> < <key_len>).
  • fold_key is sing’s Key(key, keyLength): SHA-256 of the whole key, truncated to key_len. A 32-byte key given to an aes-128 method is therefore accepted and becomes the first 16 bytes of its SHA-256 digest.

In the app, every PSK goes through this path: Ss2022ServerConfig::from_password for the server’s PSK, Ss2022User::from_password for each user, and the outbound builder for each segment of the client’s password. katana’s inbound builder is the exception for user keys (see server configuration types).

pub fn session_key(psk: &[u8], salt: &[u8], key_len: usize) -> Vec<u8>;

session_key computes blake3::derive_key(SESSION_SUBKEY_CONTEXT, psk || salt) and keeps the first key_len bytes. BLAKE3 is an extendable-output function, so the first 16 bytes of the 32-byte output equal a 16-byte XOF read, which is what sing computes for aes-128.

Each direction of a connection has its own salt and therefore its own subkey: the client’s request salt keys the upstream chunk stream, and the server’s fresh response salt keys the downstream one. Both derive from the same PSK: the single PSK, or in multi-user mode the user’s own PSK (uPSK).

pub fn identity_subkey(psk: &[u8], salt: &[u8], key_len: usize) -> Vec<u8>;
pub fn eih_hash(psk: &[u8]) -> [u8; 16];

identity_subkey is session_key with IDENTITY_SUBKEY_CONTEXT. eih_hash is the first 16 bytes of blake3::hash(psk), which equal sing’s blake3.Sum512(psk)[:16].

flowchart LR
  pw["password (base64)"] --> dec["decode_psk"]
  dec --> norm["normalise_psk"]
  norm --> psk["PSK, key_len bytes"]
  salt["request or response salt"] --> sk["session_key"]
  psk --> sk
  sk --> aead["StreamAead"]
  aead --> cw["ChunkWriter / ChunkReader"]
  ipsk["identity PSK"] --> ik["identity_subkey"]
  salt --> ik
  ik --> bc["BlockCipher"]
  upsk["user PSK"] --> hash["eih_hash"]
  hash --> bc
  bc --> eih["16-byte EIH block"]
pub enum StreamAead {
Aes128(Box<Aes128Gcm>),
Aes256(Box<Aes256Gcm>),
ChaCha(Box<ChaCha20Poly1305>),
}
impl StreamAead {
pub fn try_new(method: Method, key: &[u8]) -> io::Result<Self>;
pub fn seal(&self, nonce: &[u8; 12], buf: &mut [u8]) -> [u8; TAG_SIZE];
pub fn open(&self, nonce: &[u8; 12], buf: &mut [u8], tag: &[u8]) -> io::Result<()>;
}

StreamAead dispatches at run time over the three AEADs. All of them take a 12-byte nonce, no associated data, and a detached 16-byte tag. try_new refuses a key of the wrong length (invalid aes-128-gcm key length and so on), and open maps any tag mismatch to AEAD decryption failed (InvalidData).

pub struct ChunkWriter {
aead: StreamAead,
nonce: [u8; 12],
}
impl ChunkWriter {
pub fn new(method: Method, key: &[u8]) -> io::Result<Self>;
pub fn seal_chunk(&mut self, plaintext: &[u8]) -> Vec<u8>;
pub fn write_data(&mut self, out: &mut Vec<u8>, data: &[u8]);
pub fn seal_into(&mut self, plaintext: &[u8], out: &mut Staging<'_>) -> Option<()>;
pub fn write_data_into(&mut self, data: &[u8], out: &mut Staging<'_>) -> Option<()>;
}
pub struct ChunkReader {
aead: StreamAead,
nonce: [u8; 12],
}
impl ChunkReader {
pub fn new(method: Method, key: &[u8]) -> io::Result<Self>;
pub fn open_chunk(&mut self, chunk: &[u8]) -> io::Result<Vec<u8>>;
pub fn open_in_place(&mut self, chunk: &mut [u8]) -> io::Result<usize>;
pub async fn read_sized<R>(&mut self, r: &mut R, plaintext_len: usize) -> io::Result<Vec<u8>>
where
R: tokio::io::AsyncRead + Unpin;
pub async fn read_data<R>(&mut self, r: &mut R) -> io::Result<Vec<u8>>
where
R: tokio::io::AsyncRead + Unpin;
}

A chunk is ciphertext || tag. The nonce starts at zero and helpers::crypto::increment_le advances it as a little-endian counter after every chunk sealed or opened. One counter spans the whole direction: the header chunks come first, then the data records, with no reset between them.

A record is two chunks, AEAD(len_u16_be) || AEAD(data):

Part Size Meaning
Length chunk 2 + 16 Big-endian u16 plaintext length of the data chunk, sealed.
Data chunk len + 16 The data, sealed under the next nonce.

The writer has two families of methods:

  • seal_chunk and write_data allocate Vec<u8> output. write_data splits data into MAX_PACKET_SIZE slices and skips empty data. The header builders in protocol.rs use seal_chunk; write_data is used only by the tests.
  • seal_into and write_data_into write straight into the runtime’s Staging area and return None when the room is short. Both check the room before sealing, so a failed call leaves the nonce unchanged, and write_data_into checks room for the whole record (data.len() + RECORD_OVERHEAD) before it seals the length chunk, so it never stages half a record. write_data_into also refuses data longer than MAX_PACKET_SIZE (callers split first), and for empty data it stages nothing and returns Some(()).

The reader mirrors this. open_chunk copies the plaintext out, and open_in_place decrypts where the bytes lie and returns the plaintext length. read_sized and read_data read from an AsyncRead.

pub enum RecordStep {
NeedMore,
Data {
consumed: usize,
plain: Range<usize>,
},
}
pub struct RecordDecoder {
reader: ChunkReader,
pending_len: Option<usize>,
}
impl RecordDecoder {
pub fn new(reader: ChunkReader) -> Self;
pub fn reader_mut(&mut self) -> &mut ChunkReader;
pub fn open(&mut self, wire: &mut [u8]) -> io::Result<RecordStep>;
}

The runtime contract hands a core the same unconsumed bytes again, with more appended, until the core consumes them. Decrypting a length chunk in place and then returning NeedMore would hand the core already-decrypted bytes on the next call. RecordDecoder avoids that:

  1. With no pending_len, it waits for 18 bytes (2 + TAG_SIZE), copies them, opens the copy, and stores the length in pending_len. The reader’s nonce has advanced; the wire bytes are untouched.
  2. It waits until the data chunk is complete (18 + len + 16 bytes), opens that chunk in place, clears pending_len, and returns Data { consumed, plain } with plain pointing into the slice.

An empty record (len == 0) is legal and yields an empty plain; the core skips empty ranges instead of forwarding them. This module’s own writers never produce one, because write_data and write_data_into skip empty data.

Addresses use AddressCodec::SOCKS: a type byte (0x01 IPv4, 0x03 domain, 0x04 IPv6), the address (a domain carries a one-byte length), then the big-endian port. The longest encoding is AddressCodec::MAX_LEN = 259 bytes.

Field Size Meaning
Salt key_len Random. Keys the upstream chunk stream through session_key(psk, salt).
EIH blocks 16 × hops Multi-user only. One AES block per identity key in the client’s chain.
Fixed header chunk 11 + 16 Sealed with nonce 0: type, timestamp and variable-header length.
Variable header chunk var_len + 16 Sealed with nonce 1: address, padding and initial payload.
Records repeated AEAD(len) followed by AEAD(data), nonces 2, 3, …

The fixed header (REQUEST_FIXED_LEN = 11 bytes of plaintext):

Field Size Meaning
Type 1 HEADER_TYPE_CLIENT = 0. Anything else fails with shadowsocks-2022: bad header type (expected client).
Timestamp 8 Unix seconds, big-endian u64. Checked against the 30-second window.
Variable length 2 Big-endian u16: the plaintext length of the variable header chunk.

The variable header:

Field Size Meaning
Address 7, 19, or 4 + domain length SOCKS-style destination address and port.
Padding length 2 Big-endian u16.
Padding padding length Zero bytes, ignored.
Initial payload the rest First payload bytes for the target; may be empty.

build_request pads with a random length in 1..=MAX_PADDING_LENGTH (900) whenever the initial payload is shorter than 900 bytes, and with nothing otherwise. The parser does not bound the padding length; it only requires after_addr + padding_len to fit inside the chunk, or fails with shadowsocks-2022: padding exceeds request header. A chunk too short to hold the padding-length field fails with shadowsocks-2022: truncated request header.

Field Size Meaning
Salt key_len Fresh random salt. Keys the downstream chunk stream.
Fixed header chunk key_len + 11 + 16 Sealed with nonce 0: type, timestamp, the request salt and the first payload length.
First payload chunk len + 16 Sealed with nonce 1. Always present, possibly empty.
Records repeated AEAD(len) followed by AEAD(data), nonces 2, 3, …

The fixed response header (response_fixed_len(key_len) = key_len + 11 bytes of plaintext):

Field Size Meaning
Type 1 HEADER_TYPE_SERVER = 1. Anything else fails with shadowsocks-2022: bad header type (expected server).
Timestamp 8 Unix seconds, big-endian u64. Checked against the 30-second window.
Request salt key_len The salt of the request this response answers.
Length 2 Big-endian u16: the plaintext length of the first payload chunk.

The echoed request salt binds the response to the request. The client compares it with helpers::crypto::ct_eq, a constant-time comparison, and fails with shadowsocks-2022: response salt does not match request salt on any difference.

pub fn now_unix() -> u64;
pub fn check_timestamp(epoch: u64) -> io::Result<()>;
pub fn check_timestamp_at(epoch: u64, now: u64) -> io::Result<()>;

check_timestamp_at fails with shadowsocks-2022: bad timestamp when |now - epoch| exceeds 30 seconds; a difference of exactly 30 seconds passes. Both directions check it: the server core on the request’s fixed header, and the client codec on the response’s fixed header. The server core takes its clock as a fn() -> u64 so tests can supply a fixed time; the client codec always uses now_unix. now_unix returns 0 if the system clock reads before the Unix epoch, which then fails the check.

A multi-user server listens with one identity PSK (iPSK) and knows a list of user PSKs (uPSKs). The client proves which user it is before any AEAD chunk:

  1. For each hop in its chain, the client derives identity_subkey(current_psk, request_salt), takes eih_hash(next_psk), and encrypts that 16-byte hash as one AES block. The chain is identity_keys ++ [psk], so with a single iPSK there is one block: the uPSK’s hash under the iPSK’s subkey.
  2. The client derives the session subkey from the uPSK, not from the iPSK.
  3. The server reads the salt and one 16-byte block, decrypts it with decrypt_eih(identity_psk, salt, key_len, &mut block), and looks the hash up in its Validator.
  4. A known hash yields the user’s uPSK, label and payload. The server derives the session subkey from that uPSK and continues with the fixed header. An unknown hash fails with shadowsocks-2022: unknown identity.
pub fn decrypt_eih(
identity_psk: &[u8],
salt: &[u8],
key_len: usize,
eih: &mut [u8; 16],
) -> io::Result<()>;

BlockCipher, and why multi-user needs an AES method

Section titled “BlockCipher, and why multi-user needs an AES method”
pub enum BlockCipher {
Aes128(Box<aes::Aes128>),
Aes256(Box<aes::Aes256>),
}
impl BlockCipher {
pub fn try_new(key: &[u8]) -> io::Result<Self>;
pub fn encrypt_block(&self, block: &mut [u8]);
pub fn decrypt_block(&self, block: &mut [u8]);
}

The EIH is a single raw AES block, so the identity subkey must be an AES key. BlockCipher::try_new picks AES-128 or AES-256 from the key length (16 or 32 bytes) and refuses any other with invalid AES key length. encrypt_block and decrypt_block transform the first 16 bytes of the slice and do nothing to a shorter slice.

SIP022 defines the EIH for the AES-GCM methods only. Validator::from_config enforces that on the server: a configuration with users and 2022-blake3-chacha20-poly1305 fails with shadowsocks-2022: multi-user requires an aes-gcm method, so the app refuses it at load time.

pub struct Validator<T> {
pub identity_psk: Vec<u8>,
pub hash_to_user: HashMap<[u8; 16], usize>,
pub users: Vec<(Vec<u8>, CompactString, Arc<T>)>,
}
impl<T> Validator<T> {
pub fn from_config(config: &Ss2022ServerConfig<T>) -> io::Result<Option<Self>>;
pub fn resolve(&self, hash: &[u8; 16]) -> Option<(Vec<u8>, CompactString, Arc<T>)>;
}

from_config returns Ok(None) for a configuration without users (single-PSK mode). Otherwise it builds hash_to_user from eih_hash(uPSK) to an index into users, and takes the config’s psk as identity_psk. resolve is one hash-map lookup. The validator is built once per inbound and shared as Arc<Validator<T>> by every connection’s core.

The outbound password is iPSK:...:uPSK. The app (app/src/outbound/mod.rs, the "shadowsocks" arm) and katana’s outbound builder split it on :, run every segment through decode_psk and normalise_psk, pop the last segment as the session PSK, and pass the rest as identity_keys:

impl Ss2022Stream {
pub fn new(
method: Method,
psk: Vec<u8>,
identity_keys: Vec<Vec<u8>>,
dest: &Destination,
) -> Self;
}
Password psk identity_keys EIH blocks on the wire
uPSK uPSK empty none
iPSK:uPSK uPSK [iPSK] 1
iPSK1:iPSK2:uPSK uPSK [iPSK1, iPSK2] 2

An empty segment (a::b, or an empty password) decodes to zero bytes and fails with shadowsocks-2022: PSK too short (0 < <key_len>).

pub struct Ss2022User {
pub psk: Vec<u8>,
pub email: String,
}
impl Ss2022User {
pub fn new(method: Method, psk: Vec<u8>) -> io::Result<Self>;
pub fn from_password(method: Method, password: &str, email: String) -> io::Result<Self>;
}
pub struct Ss2022ServerConfig<T> {
pub method: Method,
pub psk: Vec<u8>,
pub psk_data: Arc<T>,
pub users: Vec<(Ss2022User, Arc<T>)>,
}
impl<T> Ss2022ServerConfig<T> {
pub fn new(method: Method, psk: Vec<u8>, data: Arc<T>) -> io::Result<Self>;
pub fn from_password(method: Method, password: &str, data: Arc<T>) -> io::Result<Self>;
}

psk has two meanings, chosen by whether users is empty:

Mode users psk is Session PSK Flow user
Single-PSK empty the session PSK psk empty username, psk_data
Multi-user non-empty the identity PSK (iPSK) the matched user’s psk the user’s email as username, that user’s payload

T is the per-user payload carried into the Flow: the app uses (), and katana uses its own user tag. katana always builds the multi-user form and takes each uPSK from the first key_len() bytes of the user’s panel UUID string. It refuses a node with no users (shadowsocks node requires at least one user) and a user whose UUID string is shorter than key_len() (shadowsocks-2022 user <uid> key too short (< <key_len>)).

The struct fields are public, and the tests and katana build Ss2022User values directly. Only the constructors normalise PSKs. Nothing downstream checks the length, so code that fills the fields itself must supply keys of exactly key_len() bytes to stay compatible with other SIP022 implementations.

pub struct Ss2022Core<T> {
config: Arc<Ss2022ServerConfig<T>>,
validator: Option<Arc<Validator<T>>>,
sniff: bool,
source: Option<IpAddr>,
now: fn() -> u64,
timing: Timing,
state: State,
session: Option<(Vec<u8>, NetworkUser<T>)>,
request_salt: Vec<u8>,
reader: Option<ChunkReader>,
records: Option<RecordDecoder>,
writer: Option<ChunkWriter>,
prefix: SniffPrefix,
flow: Option<Flow<T>>,
}
impl<T> Ss2022Core<T> {
pub const BUF_SIZE: usize = 32 * 1024;
pub fn new(
config: Arc<Ss2022ServerConfig<T>>,
validator: Option<Arc<Validator<T>>>,
sniff: bool,
source: Option<IpAddr>,
now: fn() -> u64,
) -> Self;
pub fn with_system_clock(
config: Arc<Ss2022ServerConfig<T>>,
validator: Option<Arc<Validator<T>>>,
sniff: bool,
source: Option<IpAddr>,
) -> Self;
pub fn is_established(&self) -> bool;
}
impl<T: Send + Sync + 'static> ProxyCoreDecode for Ss2022Core<T> {
type Key = Single;
type Target = Flow<T>;
type Error = io::Error;
type TransportAddr = ();
const STAGING_RESERVE: usize = 32 + 1 + 8 + 32 + 2 + 2 * TAG_SIZE + RECORD_OVERHEAD + 115;
// ...
}

The core is generic over the user payload T, has one outbound (Single), and runs over a byte stream (TransportAddr = ()). The app’s serve.rs builds one per accepted connection with Ss2022Core::with_system_clock(config.clone(), validator.clone(), sniff, source) and drives it with ProxyServerRuntime sized to Ss2022Core::<()>::BUF_SIZE. katana’s serve.rs does the same with its user tag as T.

enum State {
Salt,
Fixed,
Variable(usize),
Sniff,
Relay(Passthrough<Single>),
Done,
}
stateDiagram-v2
  [*] --> Salt
  Salt --> Fixed: salt and EIH resolved, reader keyed
  Fixed --> Variable: fixed chunk opened, timestamp in window
  Variable --> Relay: flow opened
  Variable --> Sniff: sniffing an IP target, verdict More
  Sniff --> Relay: host found, budget spent, deadline or EOF
  Salt --> Done: TransportEof
  Fixed --> Done: TransportEof
  Variable --> Done: TransportEof
  Relay --> [*]: both halves closed, idle or outbound gone
  Done --> [*]

Every Event::Transport and Event::Outbound first calls Timing::touch, which arms HANDSHAKE_TIMEOUT on the first byte event and refreshes RELAY_IDLE_TIMEOUT while relaying. Each state consumes only a whole unit and returns Ok(0) until it has one:

State Waits for Does
Salt key_len bytes, plus 16 with a validator Resolves the session PSK and user (single-PSK, or through decrypt_eih and Validator::resolve), builds the ChunkReader from session_key(psk, salt), remembers the request salt.
Fixed REQUEST_FIXED_LEN + TAG_SIZE = 27 bytes Opens the chunk in place, checks the type, checks the timestamp against (self.now)(), stores var_len.
Variable(var_len) var_len + TAG_SIZE bytes Opens the chunk, parses the destination and payload range, builds the Flow, moves the reader into a RecordDecoder, then opens the flow or starts sniffing.
Sniff whole records Feeds each record’s plaintext to SniffPrefix::push until the verdict is not More, then opens the flow with the sniffed result.
Relay whole records Pushes one Effect::Forward per non-empty record, with ranges into the transport slice.
Done nothing Consumes nothing.

A server that never received a whole unit has decrypted nothing, so the runtime can hand the same bytes back safely. The Fixed and Variable states open their chunks only once data.get_mut(..len) returns the whole chunk; the record states rely on RecordDecoder.

Sniffing runs only if the inbound enables it and sniff::worth_sniffing accepts the destination, that is, when the destination is an IP address. Then:

  • The initial payload from the variable header goes into SniffPrefix. If the collector wants more, the core enters State::Sniff and Timing::enter(Phase::Sniff) arms SNIFF_TIMEOUT (300 ms).
  • In State::Sniff each record’s plaintext goes into the prefix. The prefix takes at most SNIFF_LIMIT (4 KiB) in total.
  • Once the verdict is not More, open_sniffed copies the result into flow.sniffed, pushes Effect::Open, then Effect::ForwardHeld over everything collected, then Effect::Forward for any bytes of the last record that the prefix did not take.
  • Event::Deadline with Expired::Sniff, or Event::TransportEof, opens the flow with whatever the prefix holds.

Without sniffing, the core pushes Effect::Open and, if the initial payload is non-empty, an Effect::Forward over its range in the variable chunk, with no copy. Opening moves the core to State::Relay(Passthrough::new(Single)) and Phase::Relay, and is_established() becomes true. The first byte event in relay calls SniffPrefix::clear, once the runtime has applied the ForwardHeld that referenced the held bytes.

The core writes nothing until the target answers. The first Event::Outbound in relay calls start_response, which calls build_response(method, session_psk, request_salt, first) with the first min(len, MAX_PACKET_SIZE) bytes as the first payload, stages it, and keeps the returned ChunkWriter. The rest of that event, and every later event, goes out as records through write_data_into in MAX_PACKET_SIZE slices. Event::Outbound before relay is acknowledged and dropped: no outbound exists yet to produce it.

If the target closes without sending anything, Event::OutboundEof stages a response with an empty first payload before the half-close, so a client that reads the response header before it accepts the end of stream still gets a complete header. (Ss2022Stream itself would also accept a bare end of stream before any response byte: the client runtime treats an end of file with no unparsed bytes as clean.)

sequenceDiagram
  participant C as Ss2022Stream
  participant S as Ss2022Core
  participant T as Target
  C->>S: salt, EIH, fixed chunk, variable chunk
  Note over S: resolve user, check timestamp, parse address
  S->>T: Effect::Open, then Forward of initial payload
  C->>S: records
  S->>T: Effect::Forward per record
  T-->>S: first downlink bytes
  S-->>C: response salt, fixed chunk with request salt, first payload
  T-->>S: more bytes
  S-->>C: records
Event In the handshake states In Sniff In Relay
TransportEof State::Done, Effect::Finish Opens the flow, then Passthrough::on_transport_eof Passthrough::on_transport_eof: shuts the outbound’s write half down
OutboundEof ignored ignored Empty response header if none was sent, then Passthrough::on_outbound_eof
ConnectFailed, OutboundError logged at debug, nothing else logged at debug, nothing else Logged at debug, then Passthrough::on_outbound_gone: shut the transport down and finish
Deadline Expired::Handshake returns handshake_timed_out() (client did not complete its request in time, TimedOut) Expired::Sniff opens the flow Expired::Idle: Timing has already pushed Effect::Finish
Connected, Datagram, SendFailed, TransportDatagram, TransportSendFailed ignored ignored ignored

The outbound events can only arrive after Effect::Open, which moves the core to Relay, so in practice the first two columns never see them.

Any Err from handle ends the connection: a failed AEAD open, a bad header type, the timestamp window, an unknown identity, a malformed address or padding, or staging_full() if the staging room were ever below the declared reserve. The runtime wraps a core error as proxy core: <error>, and the app’s serve.rs logs it at debug level as shadowsocks-2022 connection from <source> ended: proxy core: <error>, where <source> is the client IP formatted as an Option (Some(…)). The app’s drive loop also waits at most HANDSHAKE_TIMEOUT for each runtime step while the core is not established, and otherwise fails with inbound handshake timed out after 10s, because a client that never sends a byte produces no runtime event and so never reaches the core’s own deadline.

pub struct Ss2022Stream {
method: Method,
psk: Vec<u8>,
identity_keys: Vec<Vec<u8>>,
dest: Destination,
request_salt: Vec<u8>,
writer: Option<ChunkWriter>,
down: Down,
}
impl ProxyCoreEncodeHandshake for Ss2022Stream {
type Target = Destination;
type Error = io::Error;
const STAGING_RESERVE: usize = 2048;
fn start(&mut self, out: &mut Staging<'_>) -> io::Result<Handshake>;
fn reply(&mut self, _: &mut [u8], _: &mut Staging<'_>) -> io::Result<Reply>;
fn finish(&mut self, _: &mut Staging<'_>) -> io::Result<()>;
}
impl ProxyCoreEncode for Ss2022Stream {
fn seal(&mut self, plain: &[u8], out: &mut Staging<'_>) -> io::Result<usize>;
fn open(&mut self, wire: &mut [u8]) -> io::Result<Opened>;
}
  • start calls build_request with an empty initial payload, stages the header, keeps the writer and the request salt, and returns Handshake::Done: plaintext may follow at once, with no round trip. Because the payload is empty, the request always carries 1 to 900 bytes of padding.
  • reply is never called for a Done handshake and returns an error if it is. finish stages nothing: SIP022 has no close frame, so the client runtime half-closes the transport.
  • seal takes at most MAX_PACKET_SIZE bytes and writes one record with write_data_into. The client runtime offers at most room - STAGING_RESERVE bytes, so the record always fits; a short room would be a runtime bug and returns shadowsocks-2022: staging room below the declared reserve. Sealing before start returns shadowsocks-2022: sealed before start.
enum Down {
Salt,
Fixed(ChunkReader),
FirstPayload(ChunkReader, usize),
Records(RecordDecoder),
}
stateDiagram-v2
  [*] --> Salt
  Salt --> Fixed: key_len bytes, reader keyed from the response salt
  Fixed --> FirstPayload: type, timestamp and echoed salt checked
  FirstPayload --> Records: first payload opened
  Records --> Records: one record per call

open takes the state out with std::mem::replace and puts it back on every NeedMore, so a partial read leaves the codec where it was. Each step returns Opened::Frame:

State Consumes plain
Salt key_len empty
Fixed response_fixed_len(key_len) + TAG_SIZE empty
FirstPayload first_len + TAG_SIZE the first payload, possibly empty
Records one record the record’s data, possibly empty

The codec never returns Opened::End: the stream ends at the transport’s end of file. After an error the codec is left in Down::Salt; the runtime tears the connection down and does not call it again.

Invariant Enforced by Pinned by
Each direction has its own salt, subkey and nonce counter; the counter starts at zero and spans header chunks and records. session_key, ChunkWriter::new and ChunkReader::new with a zero nonce, increment_le after every chunk tcp_header_roundtrip_all_methods (protocols/tests/unit/ss_2022/protocol.rs)
A response is accepted only if it echoes the request’s salt. parse_response_fixed plus ct_eq in Ss2022Stream::open and read_response response_salt_binding_is_checked (protocols/tests/unit/ss_2022/protocol.rs), request_header_then_records_and_a_bound_response (protocols/tests/unit/ss_2022/codec.rs)
A header more than 30 seconds from the local clock is refused. check_timestamp_at in the Fixed state and in Down::Fixed eih_selects_the_user_and_a_stale_timestamp_is_refused (protocols/tests/unit/ss_2022/core.rs), request_and_response_headers_decode_from_slices (protocols/tests/unit/ss_2022/protocol.rs)
The header type byte matches the direction. parse_request_fixed, parse_response_fixed Exercised by every round trip; no dedicated negative test.
No bytes are decrypted twice when the runtime re-presents an unconsumed slice. Whole-chunk checks in the Fixed and Variable states and in Down; RecordDecoder::pending_len for records request_and_response_headers_decode_from_slices (feeds a record as 10, 20, then all bytes), single_psk_request_opens_with_its_first_payload (a 40-byte slice yields only the salt), request_header_then_records_and_a_bound_response (NeedMore on a partial response)
A multi-user server selects the user by the EIH, and refuses unknown identities. decrypt_eih and Validator::resolve in on_salt eih_selects_the_user_and_a_stale_timestamp_is_refused, eih_decrypts_to_the_user_hash, tcp_eih_roundtrip_multi_user
Multi-user needs an AES method. Validator::from_config No automated test; etemenanki-app --test on a config shows the error.
A PSK is exactly key_len() bytes after construction. normalise_psk in every constructor No automated test; etemenanki-app --test on a config shows the error.
A failed staged seal leaves the nonce unchanged and never stages half a record. Room checks at the top of seal_into and write_data_into No dedicated test.
The client always sees a response header, even from a silent target. start_response(&[]) on OutboundEof a_target_that_never_answers_still_gets_an_empty_response_header (protocols/tests/unit/ss_2022/core.rs)
Sniffed bytes are forwarded once, ahead of the rest. SniffPrefix plus Effect::ForwardHeld, with took offsetting the next Forward sniffing_reads_records_until_a_host_appears (protocols/tests/unit/ss_2022/core.rs)
Name Value Where Meaning
Ss2022Core::BUF_SIZE 32 KiB core.rs Server read buffer. A header chunk or record must fit, or the runtime fails the connection with FrameTooLarge (protocol frame exceeds the read buffer).
SS2022_BUF 32 KiB app/src/outbound/mod.rs Client runtime buffer for Ss2022Stream (katana’s outbound uses the same size). A response header chunk or record must fit, or the client runtime fails with upstream frame larger than the client runtime's buffer.
Ss2022Core::STAGING_RESERVE 256 bytes core.rs Room guaranteed per event beyond the payload. The expression adds the largest response header without its payload (32-byte salt, 1 + 8 + 32 + 2 bytes of fixed header, two tags: 107 bytes), one record’s overhead (34) and 115 bytes of slack, so the first downlink event can stage the header and a following record.
Ss2022Stream::STAGING_RESERVE 2048 bytes codec.rs Room for the request header with full padding and an identity chain, or one record’s overhead.
MAX_PACKET_SIZE 65 535 bytes crypto.rs Largest plaintext per chunk.
MAX_PADDING_LENGTH 900 bytes protocol.rs Upper bound of the padding build_request writes.
Timestamp window ±30 s check_timestamp_at Inclusive.
HANDSHAKE_TIMEOUT 10 s protocols/src/core/mod.rs The core’s deadline from the first byte event until the request is parsed (sniffing then switches to SNIFF_TIMEOUT); the app’s drive loop also uses it as the per-step wait until the core is established.
SNIFF_TIMEOUT 300 ms protocols/src/sniff/mod.rs Sniffing window after the request.
SNIFF_LIMIT 4 KiB protocols/src/sniff/mod.rs Bytes the sniffer inspects across records.
RELAY_IDLE_TIMEOUT 300 s protocols/src/core/mod.rs Relay with no bytes in either direction.

The app builds these types at load time, so etemenanki-app --test -c <file> shows these errors without starting a listener:

Condition Error
Unknown 2022- method, or wrong case inbound <tag>: unknown shadowsocks-2022 method "<name>"
Password not valid base64 decode PSK: <base64 error>
PSK shorter than the key length shadowsocks-2022: PSK too short (<len> < <key_len>)
Users with 2022-blake3-chacha20-poly1305 shadowsocks-2022: multi-user requires an aes-gcm method

A PSK longer than the key length is accepted and folded, as described under PSK decoding.

Unit tests are compiled into the library through #[path] modules. The pipeline tests run the server core and the client codec over real sockets.

Terminal window
cargo test -p etemenanki-protocols --lib ss_2022
cargo test -p etemenanki-protocols --test pipeline ss2022
Test File What it pins
tcp_header_roundtrip_all_methods protocols/tests/unit/ss_2022/protocol.rs For all three methods, the request, a record, and a bound response with a record round-trip through the async readers (translated from sing’s service_test.go).
response_salt_binding_is_checked protocols/tests/unit/ss_2022/protocol.rs A response echoing another salt fails with InvalidData.
tcp_eih_roundtrip_multi_user protocols/tests/unit/ss_2022/protocol.rs read_request_multi resolves the right user; a resolver that does not know the user refuses the request.
request_and_response_headers_decode_from_slices protocols/tests/unit/ss_2022/protocol.rs For all three methods, the slice parsers agree with the builders; a request timestamp 60 seconds off fails the window; RecordDecoder returns NeedMore on partial slices and then opens the whole record; the response echoes the request salt.
eih_decrypts_to_the_user_hash protocols/tests/unit/ss_2022/protocol.rs The first EIH block decrypts to eih_hash(uPSK) under the iPSK’s identity subkey.
single_psk_request_opens_with_its_first_payload protocols/tests/unit/ss_2022/core.rs The core takes only the salt from a partial slice, then opens and forwards the initial payload in place.
eih_selects_the_user_and_a_stale_timestamp_is_refused protocols/tests/unit/ss_2022/core.rs The username comes from the matched user; an unknown identity and a clock of 1_000 are refused.
sniffing_reads_records_until_a_host_appears protocols/tests/unit/ss_2022/core.rs An HTTP Host split across the header and a record is sniffed; ForwardHeld covers all 41 collected bytes.
the_response_opens_with_the_codec_and_binds_the_request_salt protocols/tests/unit/ss_2022/core.rs The core’s response and records decode through Ss2022Stream.
a_target_that_never_answers_still_gets_an_empty_response_header protocols/tests/unit/ss_2022/core.rs OutboundEof without downlink bytes stages a 107-byte response (aes-256: salt, fixed chunk, empty payload chunk) and ShutdownTransport.
request_header_then_records_and_a_bound_response protocols/tests/unit/ss_2022/codec.rs The codec’s request parses, NeedMore on a partial response, the first payload and records concatenate, and a response bound to another salt fails.
ss2022_new_server_vs_new_client_tcp protocols/tests/pipeline/shadowsocks.rs For all three methods, 70 000 bytes echo through the server runtime and the client runtime, then a clean end of stream.
ss2022_multi_user_new_server_vs_new_client protocols/tests/pipeline/shadowsocks.rs The same echo through a multi-user server with an iPSK:uPSK client.

When you add a test, keep the negative cases next to the positive ones: a changed header field, a wrong key, a shifted clock. The core tests use CoreHarness, whose feed keeps handing the core the unconsumed tail of one slice until the core consumes nothing more, and returns the total consumed; to test a partial read, feed a prefix and then the rest, as single_psk_request_opens_with_its_first_payload does. Timestamp cases use Ss2022Core::new with a fixed clock.