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.
A request that does carry MESSAGE-INTEGRITY is admitted to the session
path only once its NONCE is shown to be one this server issued to that
source address — the return-routability proof, and a strict prerequisite
for any successful authentication. Everything the session path would reject
before the credential lookup is answered here instead, in
check_stun_auth's own order and with its own bytes: 400 for a missing
or malformed REALM/USERNAME/NONCE, 437/441 for a realm that is not the
session's, and 438 Wrong nonce for a nonce this server cannot have
issued. So a spoofed-source flood that appends a garbage MESSAGE-INTEGRITY
no longer allocates a socket and a session per packet.
These replies are answered to an unverified source, so under
--unauthorized-ratelimit they spend from the same per-source budget as the
401 (logged as unauthorized-response rate-limit exceeded). They are poor
reflection amplifiers to begin with — a 438 costs the attacker a ≥76-byte
request for a 72-byte reply, under 1:1, against 3.6:1 for the 401 that a
bare 20-byte header elicits — but a flood is capped all the same, while the
one 438 a real client needs when its nonce expires is not.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: an unauthenticated flood allocates nothing at all, whether or not it carries a MESSAGE-INTEGRITY attribute. Only a source that echoes back a nonce this server issued to it gets a session.
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.handle_turn_connection_bind() rejects on a
datagram socket before reading any attribute (400), and a ticketless
REFRESH under --mobility, which reaches 437 Invalid allocation when the
source has no allocation. Both matter because neither reply is a 401/438,
so neither qualifies for the challenge-session teardown — before this, each
such packet from a fresh source held a session for the full 60s
to-be-allocated timeout. The REFRESH shortcut is taken only when no attribute
could steer the reply elsewhere: a MOBILITY-TICKET (the actual resume path),
a malformed LIFETIME (400), an address-family attribute (443), or any
comprehension-required attribute the handler does not know (420) all fall
back to the session path, so a newly added attribute fails safe.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), the server log must show the fast path actually engaged, and
examples/scripts/stateless_nonce_forged_mi.py drives the forged-integrity
shapes and pins each reply code (438/437/441/400, and 401 for a request whose
nonce does verify).
--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=false (config file:
stateless-nonce=false). --stateless-nonce-secret implies it.--stateless-nonce=false.438 re-auth round-trip, as does a retry that lands on a
different instance behind a load balancer. Set --stateless-nonce-secret
across the fleet to avoid both.--secure-stun BINDING challenges benefit
from the challenge-session teardown, but not from the nonce fast path.