OpenSnell

Protocol Reference

Snell v4 / v5 TCP frame layout, padding interleave, dynamic record sizing, and the QUIC envelope side-by-side.

This page is the protocol-level reference. If you just want to use OpenSnell, skip to Server config — this is written for people who want to understand or reimplement the wire format.

Key derivation

For both TCP and QUIC, the per-flow AES key comes from the same KDF:

K_32  = Argon2id(psk_utf8, salt, t=3, m=8 KiB, p=1)
key   = K_32[:16]                       // AES-128-GCM
  • psk_utf8 is the configured PSK string, taken raw — not base64 decoded.
  • salt is a 16-byte random value sent at the start of each flow (per-direction for TCP; once per envelope for QUIC).
  • t=3, m=8 KiB, p=1 are the exact parameters used by Surge's snell-server v5.0.1. Mismatched parameters produce a tag-verification failure on the first decrypt and the connection is silently dropped.

The fact that KDF parameters are baked in (not negotiated) is what keeps the wire format steganographically clean — there's no version byte to fingerprint.

TCP frame layout (v4 / v5)

A snell TCP stream begins with the per-direction 16-byte salt, then a sequence of AEAD frames.

[salt(16B, sent once per direction)]
[frame_1]
[frame_2]

Each frame is two AEAD seals back-to-back, sharing a monotonically incrementing nonce counter:

frame = AEAD-Seal(K, nonce=N,   header_7B)            || 16B tag
        || padding(padLen)
        || AEAD-Seal(K, nonce=N+1, payload[payloadLen]) || 16B tag

header_7B = [0x04, 0x00, 0x00, padLen_be(2B), payloadLen_be(2B)]
nonce     = 12-byte little-endian counter, ++ per AEAD-Seal call

Padding interleave

The padding region between the two AEAD seals isn't sent in the clear in the usual sense. Bytes at even indices of the padding are swapped with the leading bytes of the payload ciphertext (see swapPadding), so the raw padding bytes never appear contiguously on the wire.

This matters for traffic-shape obfuscation: a passive observer counting "runs of high-entropy bytes" can't easily distinguish padding from payload, because they're shuffled.

First-frame ones/zeros balance

The first frame on every stream carries an extra 0x100..0x1FF-byte padding chosen so the overall ones/zeros ratio of salt + padding + ciphertext stays within a "natural" range — i.e., the first packet on the wire doesn't have a TLS-handshake-flavored byte distribution.

Dynamic Record Sizing (v5)

Later frames scale the maximum payload up from a small initial size (~1.5 KB) to MaxPayloadLength over the first several frames, then reset back to small after a 30 s idle window. This is the v5 Dynamic Record Sizing optimization — it lets the first few frames behave like a normal "small message" application (lower latency, less obvious bulk-transfer pattern) and only ramps up for sustained flows.

CONNECT request header (TCP, plaintext after AEAD decrypt)

The first frame's payload, on a fresh stream, is the CONNECT request:

request_header = [version_1B, cmd_1B, clientID_len_1B, clientID,
                  hostlen_1B, host, port_be_2B]

version  = 0x01
cmd      = 0x01 (CONNECT) | 0x05 (PING) | 0x06 (CONNECT v2 / reuse)
                          | 0x07 (UDP-over-TCP)
clientID = optional; len=0 in OpenSnell
host     = ASCII hostname or IPv4/v6 literal

cmd = 0x06 (CONNECT v2) is what enables reuse mode: the server half-closes its write side with a zero-length payload frame after the upstream socket EOFs, and the client may then send another CONNECT on the same TCP without re-doing the salt+frame handshake.

QUIC envelope layout (v5 only, client → server, first packet of a flow)

[salt(16B random)]
[AEAD-Seal(K, nonce=0, [0x04, 0, 0, padLen_be, payloadLen_be]) || 16B tag]
[padding(padLen)]
[AEAD-Seal(K, nonce=1, request_header || inner_QUIC_packet) || 16B tag]

request_header = [0x01, 0x01, 0x00, hostlen, host, port_be]
K              = Argon2id(psk_utf8, salt, 3, 8 KiB, 1, 32)[:16]
AEAD           = AES-128-GCM

Note the structural symmetry with TCP frames: the same 7-byte header, the same padding interleave (well, not the swap — the QUIC envelope is one packet, so there's no streaming concern), the same KDF, the same AEAD primitive. Once the envelope is decoded and the (client_src, upstream) mapping is recorded, every subsequent UDP packet in either direction is forwarded as raw QUIC with no additional snell framing.

See QUIC mode for the full state machine and how the format was reverse-engineered.

UDP-over-TCP datagrams

When cmd = 0x07, the snell stream carries DNS-style UDP datagrams inside the TCP frame envelope. Each datagram is framed as:

[hostlen_1B, host, port_be_2B, datalen_be_2B, data]

Multiple datagrams can be concatenated in one frame's payload. This is what udp = true on the server and SOCKS5 UDP ASSOCIATE on the client both rely on. It is not the same path as QUIC mode — QUIC mode talks raw UDP on a separate listener, UDP-over-TCP is multiplexed over the snell TCP stream.

References

  • MetaCubeX/mihomo #2816 — earlier reverse-engineered v5 proposal, described the AEAD frame layout and padding interleave.
  • MetaCubeX/mihomo #2817 — merged outbound + inbound for mihomo; the TCP protocol layer in OpenSnell is a port of that code adapted into a standalone server/client and stripped of v1/v2/v3 support.
  • Surge snell release notes — upstream's published feature list per release.

On this page