12 KiB
Stateless nonce mode (--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.
Why
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 (PR #1768) discards malformed floods cheaply, but a structurally valid STUN Allocate from a spoofed source still commits real resources before any authentication:
- a child UDP socket in the listener's address map,
- an ~18KB
ts_ur_super_session, allocated just to hold the nonce, - a to-be-allocated timer that keeps both alive for 60 seconds
(
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.
What it does
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:
- Listener fast path (
udp_stateless_fast_pathindtls_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, stray responses, and — under--stun-only— any non-BINDING method) are dropped with no state either. This drop discipline runs in every credential mode, not just long-term: without it a no-auth/short-term server built a socket and a ~19KB session for each such packet and held it for the 60s to-be-allocated timeout, so a spoofed-source flood exhausted memory and OOM-killed the process (GHSA-4xqq-hcgg-4mcw, CWE-770). Only the 401/438 challenge synthesis is long-term/stateless-nonce specific; in no-auth mode a real request (e.g. ALLOCATE) still takes the session path and an erroring one is reaped on the initial packet. The--unauthorized-ratelimitresponse 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 itsNONCEis 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, incheck_stun_auth's own order and with its own bytes:400for a missing or malformed REALM/USERNAME/NONCE,437/441for a realm that is not the session's, and438 Wrong noncefor 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-ratelimitthey spend from the same per-source budget as the 401 (logged asunauthorized-response rate-limit exceeded). They are poor reflection amplifiers to begin with — a438costs 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 one438a real client needs when its nonce expires is not. - Fresh-session nonce acceptance (
check_stun_authinns_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 withturn_time()while the validator compares against its cachedctime). 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 with438 Wrong nonce. - Challenge-session teardown: a UDP session whose response was only an auth challenge (401/438) and that has no allocation is torn down immediately after the response is written, instead of lingering for the 60s to-be-allocated timeout. This still covers every session-path challenge — a stale nonce on an established client, TLS/DTLS clients, a nonce that verifies but whose credentials do not.
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.
Wire compatibility
The mode is designed to be invisible to clients:
- The fast-path 401 carries the same attributes in the same order as the
session path's
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 byORIGINon ALLOCATE is replicated. - A nonce is opaque to clients; only its value and length changed. RFC 8489 relies on MESSAGE-INTEGRITY over the (random) transaction ID for per-request freshness — nonce reuse within its lifetime is already how the protocol works, and binding the nonce to the client's address is strictly stronger than a stored random nonce (a captured nonce is useless from another address).
- Established sessions keep the exact stock stale-nonce behavior: the session
caches its nonce,
--stale-nonceexpiry regenerates it and answers438, and the client re-authenticates as usual. - Two methods that skip the challenge entirely still have a reply fixed by the
request bytes alone, so they are answered here rather than falling back:
CONNECTION-BIND, which
handle_turn_connection_bind()rejects on a datagram socket before reading any attribute (400), and a ticketless REFRESH under--mobility, which reaches437 Invalid allocationwhen the source has no allocation. Both matter because neither reply is a401/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. - Cases the fast path cannot replicate byte-for-byte fall back to the session path automatically: BINDING (RFC 5780 alternate sockets), ALLOCATE when alternate/aux server redirection (300) is configured, and any request whose nonce verifies (the credential lookup itself may be asynchronous, and resuming it needs a session).
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).
Shared fleet key (--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:
- Entropy is on you. The KDF fixes the key length, not the guessing entropy: an attacker who collects a few 401s can mount an offline dictionary attack against a weak passphrase. Use a long random string.
- Leak impact is bounded. This key signs DoS-hardening cookies, not credentials: forging nonces never authenticates anyone (MESSAGE-INTEGRITY is still verified), it only lets spoofed traffic reach the credential lookup again - roughly the pre-feature load, with memory still bounded by the challenge-session teardown. Still, prefer the config file over the command line, where the secret is visible in the process list.
- Clocks must agree. Cross-instance validation compares the nonce's
embedded issue time against the validating server's clock; keep the fleet
NTP-synced well within the
--stale-noncelifetime (the future-skew allowance is a few seconds). - Rotation currently means changing the secret and restarting; clients
re-authenticate via one standard
438round-trip. Accepting an old and a new secret simultaneously (sign with new, verify with both) is left as future work.
Limitations
- On by default. Disable with
--stateless-nonce=false(config file:stateless-nonce=false).--stateless-nonce-secretimplies it. - Nonce length. Challenges carry a 24-char nonce instead of the legacy
16-char one. RFC 8489 requires clients to treat the nonce as an opaque
string of up to 128 characters, so compliant clients are unaffected; a
hypothetical client with a hard-coded 16-char nonce buffer would need
--stateless-nonce=false. - Restarts and fleets. The default key is ephemeral, so a restart costs
each client one
438re-auth round-trip, as does a retry that lands on a different instance behind a load balancer. Set--stateless-nonce-secretacross the fleet to avoid both. - BINDING does not use this path. BINDING is answerable without
authentication and is short-circuited by its own listener fast path, which
is independent of this option.
--secure-stunBINDING challenges benefit from the challenge-session teardown, but not from the nonce fast path. - TCP/TLS are unchanged. Those transports cannot be source-spoofed and already pin a connection per client; sessions there keep stock behavior (the derived nonce is used, which is harmless).