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:
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).
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:
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.gosample[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.
| Protocol | Value remembered | Window | Backstop when filter forgets |
|---|---|---|---|
| VMess | 16-byte encrypted AuthID | 120 s ×2 gen | ±120 s timestamp inside AuthID |
| SS2022 | Request salt (via sing) | 60 s per salt | ±30 s timestamp in the header |