Contents

Anti-replay

Cheap replay protection that stops captured handshakes from being reused.

A censor that records your traffic doesn’t need your key to probe your server — it can just resend the bytes it captured. If the server accepts the same handshake twice, an active prober learns that those 16 bytes authenticate, which is enough to fingerprint the server. The common/antireplay package is Xray’s answer: a tiny stateful filter that remembers every handshake it has recently accepted.

Two rotating maps give expiry without timers

The filter never expires individual entries — it throws away a whole generation at once. ReplayFilter keeps two plain Go maps, poolA and poolB. Every Check first looks at the clock: if more than interval has passed since the last rotation, poolA is demoted to poolB and the old poolB is dropped for the garbage collector. Then the value is looked up in both pools, and inserted into poolA only if neither has it:

common/antireplay/mapfilter.go
func (filter *ReplayFilter[T]) Check(sum T) bool {
	// …
	now := time.Now()
	if now.Sub(filter.lastClean) >= filter.interval {
		filter.poolB = filter.poolA
		filter.poolA = make(map[T]struct{})
		filter.lastClean = now
	}

	_, existsA := filter.poolA[sum]
	_, existsB := filter.poolB[sum]
	if !existsA && !existsB {
		filter.poolA[sum] = struct{}{}
	}
	return !(existsA || existsB)
}

Check returns true for “never seen, now recorded” and false for “replay”. An entry survives the rotation that demotes it into poolB and is only dropped by the rotation after that, so it is remembered for at least interval — and under steady traffic at most 2×interval — seconds. There are no background goroutines and no per-entry timestamps: rotation happens lazily inside Check, at most one swap per call, so an idle server pays nothing (and stale entries on a quiet listener simply linger unread until traffic resumes).

Check consults both generations; a hit in either rejects. Every interval, pool A becomes pool B and the old pool B is dropped wholesale.

VMess checks replay last, so attackers can’t fill the map

Only handshakes that already passed authentication ever enter the filter. The single user of this package in the tree is VMess AEAD: NewAuthIDDecoderHolder creates a NewMapFilter[[16]byte](120) keyed by the raw 16-byte encrypted AuthID from the wire. But Match gates the filter behind two cheaper checks — it AES-decrypts the AuthID per user, and only if the embedded CRC32 verifies and the embedded timestamp is within ±120 s of server time does it consult the filter:

proxy/vmess/aead/authid.go proxy/vmess/validator.go
if math.Abs(math.Abs(float64(t))-float64(time.Now().Unix())) > 120 {
	return nil, ErrInvalidTime
}

if !a.filter.Check(authID) {
	return nil, ErrReplay
}

There is nothing to configure — the 120-second window is hardcoded in NewAuthIDDecoderHolder, matched to the protocol’s timestamp rule rather than exposed as a knob.

Exact maps beat probabilistic filters here

Xray pays memory for correctness: the filter has zero false positives. Despite the “bloom-filter”-shaped design (two rotating generations is the classic rolling-bloom layout), the current implementation stores exact values in hash maps. The unit test pins this down: it inserts a random 16-byte sample, increments a single byte, and asserts the neighbor is not rejected — a guarantee no bloom or cuckoo filter can make:

common/antireplay/antireplay_test.go
sample[0]++
if !filter.Check(sample) {
	t.Error("Unexpected false positive")
}

The tradeoff is real: a probabilistic filter has fixed memory but would occasionally reject a legitimate user’s first connection, which on a censored network looks identical to being blocked. An exact map instead grows with handshake rate — but since only authenticated handshakes are inserted (previous section), that rate is your own users’, roughly connections-per-240 s × ~64 bytes of map overhead. For any realistic server that is kilobytes.

Shadowsocks-2022 in Xray makes the same call one layer down. The inbound delegates to the sing-shadowsocks library ( proxy/shadowsocks_2022/inbound.go), whose shadowaead_2022 service runs every request salt through replay.NewSimple(60 * time.Second) — an exact map with per-entry timestamps from sing/common/replay. The cuckoo-filter variant in that package exists only as commented-out code; both codebases converged on exactness. The ordering is the opposite of VMess, though: sing checks (and records) the plaintext salt before any AEAD verification, so its map does accept unauthenticated garbage and leans on the 60 s per-entry expiry to stay small.

ProtocolValue rememberedWindowBackstop when filter forgets
VMess16-byte encrypted AuthID120 s ×2 gen±120 s timestamp inside AuthID
SS2022Request salt (via sing)60 s per salt±30 s timestamp in the header