Shadowsocks 2022
Source files: 24 · checked against Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/ss_2022/mod.rsEtemenanki/protocols/src/ss_2022/crypto.rsEtemenanki/protocols/src/ss_2022/protocol.rsEtemenanki/protocols/src/ss_2022/users.rsEtemenanki/protocols/src/ss_2022/core.rsEtemenanki/protocols/src/ss_2022/codec.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/helpers/crypto.rsEtemenanki/protocols/src/helpers/address.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/core/harness.rsEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/client.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/serve.rsEtemenanki/protocols/tests/unit/ss_2022/protocol.rsEtemenanki/protocols/tests/unit/ss_2022/core.rsEtemenanki/protocols/tests/unit/ss_2022/codec.rsEtemenanki/protocols/tests/pipeline/shadowsocks.rskatana/src/inbound.rskatana/src/outbound/mod.rskatana/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).
Responsibilities
Section titled “Responsibilities”| 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 aStagingarea. - Dialing and routing belong to the app’s connector. The core emits
Effect::Openwith aFlow, and the codec is told its destination when it is built. - Configuration parsing belongs to
app/src/inbound/mod.rsandapp/src/outbound/mod.rs, which pick the 2022 family whenevermethodstarts with2022-. katana builds the same types from panel data in its own inbound and outbound builders.
Methods and key sizes
Section titled “Methods and key sizes”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. |
PSK decoding, normalisation and folding
Section titled “PSK decoding, normalisation and folding”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_psktrims surrounding whitespace and decodes with the standard base64 alphabet. A decoding failure becomesdecode PSK: <base64 error>(InvalidInput).normalise_pskkeeps a PSK of exactlykey_len()bytes, folds a longer one withfold_key, and refuses a shorter one withshadowsocks-2022: PSK too short (<len> < <key_len>).fold_keyis sing’sKey(key, keyLength): SHA-256 of the whole key, truncated tokey_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).
Session subkeys
Section titled “Session subkeys”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).
Identity subkeys and the identity hash
Section titled “Identity subkeys and the identity hash”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"]
The AEAD chunk stream
Section titled “The AEAD chunk stream”StreamAead
Section titled “StreamAead”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).
ChunkWriter and ChunkReader
Section titled “ChunkWriter and ChunkReader”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_chunkandwrite_dataallocateVec<u8>output.write_datasplits data intoMAX_PACKET_SIZEslices and skips empty data. The header builders inprotocol.rsuseseal_chunk;write_datais used only by the tests.seal_intoandwrite_data_intowrite straight into the runtime’sStagingarea and returnNonewhen the room is short. Both check the room before sealing, so a failed call leaves the nonce unchanged, andwrite_data_intochecks room for the whole record (data.len() + RECORD_OVERHEAD) before it seals the length chunk, so it never stages half a record.write_data_intoalso refuses data longer thanMAX_PACKET_SIZE(callers split first), and for empty data it stages nothing and returnsSome(()).
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.
RecordDecoder
Section titled “RecordDecoder”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:
- With no
pending_len, it waits for 18 bytes (2 + TAG_SIZE), copies them, opens the copy, and stores the length inpending_len. The reader’s nonce has advanced; the wire bytes are untouched. - It waits until the data chunk is complete (
18 + len + 16bytes), opens that chunk in place, clearspending_len, and returnsData { consumed, plain }withplainpointing 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.
Wire format
Section titled “Wire format”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.
Request (client to server)
Section titled “Request (client to server)”| 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.
Response (server to client)
Section titled “Response (server to client)”| 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.
The timestamp window
Section titled “The timestamp window”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.
Multi-user: the Extended Identity Header
Section titled “Multi-user: the Extended Identity Header”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:
- For each hop in its chain, the client derives
identity_subkey(current_psk, request_salt), takeseih_hash(next_psk), and encrypts that 16-byte hash as one AES block. The chain isidentity_keys ++ [psk], so with a single iPSK there is one block: the uPSK’s hash under the iPSK’s subkey. - The client derives the session subkey from the uPSK, not from the iPSK.
- 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 itsValidator. - 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.
Validator
Section titled “Validator”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.
Identity chains on the client
Section titled “Identity chains on the client”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>).
Server configuration types
Section titled “Server configuration types”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.
The server core: Ss2022Core
Section titled “The server core: Ss2022Core”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.
States
Section titled “States”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.
Opening the flow and sniffing
Section titled “Opening the flow and sniffing”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 entersState::SniffandTiming::enter(Phase::Sniff)armsSNIFF_TIMEOUT(300 ms). - In
State::Sniffeach record’s plaintext goes into the prefix. The prefix takes at mostSNIFF_LIMIT(4 KiB) in total. - Once the verdict is not
More,open_sniffedcopies the result intoflow.sniffed, pushesEffect::Open, thenEffect::ForwardHeldover everything collected, thenEffect::Forwardfor any bytes of the last record that the prefix did not take. Event::DeadlinewithExpired::Sniff, orEvent::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 response
Section titled “The response”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
End of stream and errors
Section titled “End of stream and errors”| 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.
The client codec: Ss2022Stream
Section titled “The client codec: Ss2022Stream”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>;}startcallsbuild_requestwith an empty initial payload, stages the header, keeps the writer and the request salt, and returnsHandshake::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.replyis never called for aDonehandshake and returns an error if it is.finishstages nothing: SIP022 has no close frame, so the client runtime half-closes the transport.sealtakes at mostMAX_PACKET_SIZEbytes and writes one record withwrite_data_into. The client runtime offers at mostroom - STAGING_RESERVEbytes, so the record always fits; a short room would be a runtime bug and returnsshadowsocks-2022: staging room below the declared reserve. Sealing beforestartreturnsshadowsocks-2022: sealed before start.
Downstream states
Section titled “Downstream states”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.
Invariants
Section titled “Invariants”| 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) |
Limits
Section titled “Limits”| 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. |
Configuration errors
Section titled “Configuration errors”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.
cargo test -p etemenanki-protocols --lib ss_2022cargo 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.