Contents

Shadowsocks

Lightweight stream cipher proxy, plus the AEAD-2022 rewrite.

Shadowsocks is the minimalist of the protocol family: no handshake, no negotiation, no certificates — a pre-shared password, an encrypted target address, and then raw payload. That minimalism is why it survived a decade, and also why Xray’s own package comment reads // R.I.P Shadowsocks. The original stream ciphers (RC4, AES-CFB…) had no integrity checks, so censors could flip ciphertext bits and use the server’s reaction as a decryption oracle. Xray doesn’t ship them: the CipherType enum jumps from UNKNOWN = 0 straight to AES_128_GCM = 5, leaving the slots where stream ciphers used to live empty, and getCipher offers exactly four AEAD ciphers plus none.

The password never encrypts anything — every connection derives its own key

Your password is stretched into a master key, and the master key only ever feeds a KDF. passwordToCipherKey runs the old OpenSSL EVP_BytesToKey scheme (repeated MD5) to get the master key. Each TCP connection then starts with a random salt (IVSize() bytes), and the actual AEAD key is derived per connection:

// proxy/shadowsocks/config.go
func hkdfSHA1(secret, salt, outKey []byte) {
	r := hkdf.New(sha1.New, secret, salt, []byte("ss-subkey"))
	common.Must2(io.ReadFull(r, outKey))
}

Everything after the salt is length-prefixed AEAD chunks — a sealed 2-byte length, then the sealed payload — with an incrementing nonce (GenerateAEADNonceWithSize). UDP repeats the trick per datagram: EncodeUDPPacket prefixes every packet with fresh random bytes from rand.Reader, and EncodePacket derives the per-packet subkey from them and seals the rest.

methodKeySaltAEAD
aes-128-gcm16 B16 BAES-GCM
aes-256-gcm32 B32 BAES-GCM
chacha20-poly130532 B32 BChaCha20-Poly1305
xchacha20-poly130532 B32 BXChaCha20 variant
noneplaintext pipe
proxy/shadowsocks/config.go proxy/shadowsocks/protocol.go

There is no user ID on the wire — the server finds you by trial decryption

A Shadowsocks header carries no identifier at all, so single-port multi-user works by brute force: Validator.Get walks every configured user, derives that user’s subkey from the salt, and tries to open the first length chunk (bs[ivLen : ivLen+18] — 2 length bytes + 16-byte tag). Whoever’s key authenticates, wins. This is also why Validator.Add rejects a second user when the cipher is not AEAD — without an authentication tag there is nothing to test a key against.

Failed authentication is never answered — the server silently drains a key-seeded number of bytes before closing
proxy/shadowsocks/validator.go

SS2022 rebuilds the handshake around keys, timestamps and replay filters

Shadowsocks 2022 is a different protocol that happens to share the config block. When method matches one of the 2022-blake3-* names in shadowaead_2022.List, Xray doesn’t run its own implementation at all — the shadowsocks_2022 package bridges into sing-shadowsocks’s shadowaead_2022 service. The differences that matter:

  • Real keys, not passwords. password must be valid base64 (base64.StdEncoding.DecodeString in NewMultiServer); a decoded PSK shorter than the key size is rejected with ErrBadKey, a longer one is hashed down via SHA-256. MD5 stretching is gone. Subkeys come from blake3.DeriveKey(..., "shadowsocks 2022 session subkey") instead of HKDF-SHA1.
  • Replay protection. The server keeps a 60-second salt filter (replay.NewSimple(60 * time.Second)) and rejects any header whose embedded timestamp is more than 30 seconds off (ErrBadTimestamp). Recording a valid handshake and replaying it — a classic probe against legacy Shadowsocks — dies at the filter.
  • EIH multi-user without trial decryption. MultiUserInbound wraps shadowaead_2022.MultiService: the client sends an identity header — the first 16 bytes of blake3.Sum512(userPSK), AES-encrypted under a subkey derived with blake3.DeriveKey(..., "shadowsocks 2022 identity subkey") — and the server decrypts it and finds the user with one map lookup (uPSKHash) instead of looping over everyone. Multi-user requires an AES method — buildShadowsocks2022 rejects everything but blake3-aes-*-gcm when clients is present. A client joins by chaining PSKs: "password": "<serverPSK>:<userPSK>".
{
	"protocol": "shadowsocks",
	"settings": {
		"method": "2022-blake3-aes-128-gcm",
		"password": "bXlzZXJ2ZXJwc2sxNmJ5dA==",
		"clients": [{ "password": "YWxpY2Vwc2sxNmJ5dGVzIQ==", "email": "alice" }]
	}
}

The SS2022 outbound also waits up to 100 ms (ReadMultiBufferTimeout) for the first application bytes so it can pack them into the handshake — one less round trip, and the first packet’s length varies with real data. "uot": true tunnels UDP over the TCP connection.

proxy/shadowsocks_2022/inbound.go proxy/shadowsocks_2022/inbound_multi.go proxy/shadowsocks_2022/outbound.go infra/conf/shadowsocks.go

Random bytes are their own fingerprint — pair it with a transport

Shadowsocks does zero obfuscation: the wire is uniformly random from byte one, and a stream with no recognizable protocol header is itself unusual enough for modern DPI to flag. There is no TLS, no forward secrecy (leak the key and all recorded legacy traffic decrypts — Xray prints a deprecation warning pointing at VLESS Encryption for exactly this reason), and nothing that imitates allowed traffic.

Don't

Run bare legacy Shadowsocks across a hostile border

aes-256-gcm on a naked TCP port is replayable, has no forward secrecy, and presents a high-entropy flow that entropy-based DPI can throttle or block without ever decrypting it.

Do

Use SS2022 — and hide the randomness

2022-blake3-aes-128-gcm kills replays and probe oracles. For DPI that targets “unidentifiable random streams”, carry it inside a transport that looks like something (WebSocket, XHTTP behind real TLS) or switch to a TLS-native protocol like VLESS + REALITY.

proxy/shadowsocks/shadowsocks.go