docs/stateless-nonce.md
--stateless-nonce)This document describes the stateless challenge-nonce mode added for issue #1999. It covers the attack it defends against, how it works, why it is wire-compatible with standard TURN clients, and its known limitations.
TURN long-term authentication is a challenge/response flow: the first request
arrives without valid credentials and the server answers 401 Unauthorized
with a REALM and a NONCE; the client retries with credentials derived
from them. The server must later recognize the nonce it handed out — and the
stock implementation does that by storing a random nonce in per-client
session state.
That storage is the problem on UDP. Early packet validation
(--drop-invalid-packets, PR #1768) discards malformed floods cheaply, but a
structurally valid STUN Allocate from a spoofed source still commits real
resources before any authentication:
ts_ur_super_session, allocated just to hold the nonce,TURN_MAX_ALLOCATE_TIMEOUT).At 10k spoofed packets/s that is hundreds of thousands of live sessions —
multiple GB of memory — from traffic that never authenticates.
--unauthorized-ratelimit (see 401-ratelimit.md)
suppresses the 401 responses (reflection defense) but the state has already
been allocated by the time it runs, so it does not bound memory.
With --stateless-nonce the challenge nonce is no longer random per session;
it is an authenticated timestamp cookie:
nonce = ts || hex( HMAC-SHA256( K, "<client-ip>:<port>|<ts>" ) )[0..15]
ts = 8 lowercase hex chars: the 32-bit issue time in seconds
24 lowercase hex characters total. K is a 256-bit key shared by every
listener and relay thread: by default an ephemeral random key generated once
at process startup, or - with --stateless-nonce-secret - a key derived from
an operator-configured secret (see "Shared fleet key" below). The issue time rides in the clear - it is covered
by the MAC, and it is nothing an observer does not already learn from seeing
the cleartext 401 itself. Because the server can validate the nonce (parse
the timestamp, recompute the MAC, check the age against the nonce lifetime -
--stale-nonce, or 600s if unset), three things become possible:
udp_stateless_nonce_fast_path in
dtls_listener.c): a MESSAGE-INTEGRITY-less request from an unknown UDP
source is answered with the 401 challenge directly from the listener — no
child socket, no session. Packets the relay would silently ignore for a
fresh source (indications, unbound channel data, STUN that fails the full
check) are dropped with no state either. The --unauthorized-ratelimit
response suppression and the Prometheus 401 counters apply to this path
exactly as they do to the session path.check_stun_auth in
ns_turn_server.c): when the client's authenticated retry arrives, the
brand-new session accepts a presented nonce whose MAC verifies for this
client address and whose issue time is no older than the nonce lifetime
(a few seconds of future skew are tolerated: the issuing listener stamps
with turn_time() while the validator compares against its cached
ctime). The session's stale-nonce expiry is then anchored to the
nonce's real issue time. Without the flag a fresh session rejects any
presented nonce with 438 Wrong nonce.The result: for MESSAGE-INTEGRITY-less floods the attacker's memory cost is zero; for garbage-MI floods it is one transient session per packet, freed as soon as the challenge is sent.
The mode is designed to be invisible to clients:
create_challenge_response: error response, NONCE,
REALM, THIRD-PARTY-AUTHORIZATION (oauth only), SOFTWARE (unless
disabled), FINGERPRINT (if the server enforces it or the request used
one). Realm selection by ORIGIN on ALLOCATE is replicated.--stale-nonce expiry regenerates it and answers 438,
and the client re-authenticates as usual.--mobility (MOBILITY-TICKET may authenticate without a first-pass
challenge), CONNECTION-BIND (exempt from the challenge), and any request
carrying MESSAGE-INTEGRITY.examples/run_tests_stateless_nonce.sh asserts all of this end-to-end: the
standard client workload must succeed unchanged over UDP/TCP (and TLS/DTLS on
Linux), and the server log must show the fast path actually engaged.
--stateless-nonce-secret)By default the key is ephemeral: generated at startup, gone at exit. That is
zero-management and leak-proof, but it means a restart invalidates
outstanding nonces (one 438 re-auth per client), and in a multi-server
deployment - a UDP load balancer, DNS round-robin, or coturn's own
ALTERNATE-SERVER balancing - a retry that lands on a different instance
likewise costs one extra 438 round-trip, and the listener fast path only
short-circuits nonces its own instance issued.
--stateless-nonce-secret=<secret> (config file: stateless-nonce-secret;
implies --stateless-nonce) derives the key from an operator-configured
secret instead:
K = SHA-256( "coturn-stateless-nonce-v1" || secret )
Every server configured with the same secret then validates every other
server's nonces, across restarts and across the fleet. The label
domain-separates this derivation, so the string can never collide with any
other credential use - but do not reuse another credential (such as
--static-auth-secret) as the value anyway.
Operational notes:
--stale-nonce lifetime (the future-skew
allowance is a few seconds).438 round-trip. Accepting an old and a
new secret simultaneously (sign with new, verify with both) is left as
future work.--stateless-nonce (config file:
stateless-nonce), or implicitly via --stateless-nonce-secret.--secure-stun BINDING challenges do benefit from the
challenge-session teardown, but not from the listener fast path.