1
0
mirror of https://github.com/coturn/coturn.git synced 2026-10-24 00:08:57 +03:00
Files
Pavel Punsky 142dd93b30 Add --stateless-nonce: bound memory under valid-structure STUN floods (#1999) (#2002)
Closes #1999.

## Problem

A structurally valid STUN request from a spoofed UDP source commits real
resources before any authentication: a child socket in the listener's
address map plus an ~18KB `ts_ur_super_session` — allocated solely to
store the random 401-challenge nonce — kept alive for 60 seconds by the
to-be-allocated timer. Early packet validation (#1768) doesn't help
against well-formed packets, and `--unauthorized-ratelimit` suppresses
only the 401 *responses*, after the state already exists. At 10k spoofed
pps that is hundreds of thousands of live sessions (multiple GB) from
traffic that never authenticates.

## Approach

With `--stateless-nonce` (off by default) the challenge nonce becomes an
**authenticated timestamp cookie** instead of stored random state:

```
nonce = <8 hex chars: 32-bit issue time>
     || <16 hex chars: HMAC-SHA256(K, "<client-addr>|<ts-hex>") truncated>
```

Because the server can *validate* a nonce (parse timestamp → recompute
MAC with a constant-time compare → check age against `--stale-nonce`,
default 600s, with a 5s future-skew allowance), three things follow:

1. **Listener fast path** (`udp_stateless_nonce_fast_path`,
`dtls_listener.c`): MESSAGE-INTEGRITY-less requests from unknown UDP
sources are answered with the 401 challenge directly from the listener —
no socket, no session. Packets the relay would silently ignore for a
fresh source (indications, unbound channel data, STUN failing the full
check) are dropped with no state either.
2. **Fresh-session nonce acceptance** (`check_stun_auth`): the client's
authenticated retry lands on a brand-new session, which accepts a
presented nonce whose MAC verifies for that client address and whose
issue time is within the lifetime. Stale-nonce expiry is anchored to the
real issue time.
3. **Challenge-session teardown**: a UDP session whose only output was a
401/438 (or whose challenge the ratelimit suppressed) is torn down
immediately instead of idling for 60s. This bounds memory against floods
that attach a garbage MESSAGE-INTEGRITY and therefore must travel the
session path.

Result: zero attacker memory cost for MI-less floods; one transient,
immediately-freed session per packet for garbage-MI floods.

`--stateless-nonce-secret=<secret>` (implies the flag) additionally
derives the key via `K = SHA-256("coturn-stateless-nonce-v1" || secret)`
so a fleet sharing the secret cross-validates nonces across restarts and
load balancers. Without it the key is ephemeral per process.

## Wire transparency

- The fast-path 401 replicates the session path byte-for-byte: same
attributes in the same order (NONCE, REALM, THIRD-PARTY-AUTHORIZATION
for oauth, SOFTWARE, FINGERPRINT), including realm selection by ORIGIN
on ALLOCATE, plus the same `--unauthorized-ratelimit` suppression and
Prometheus 401 counters.
- Anything the fast path cannot answer identically falls through to the
untouched session path: BINDING (RFC 5780), ALLOCATE under
alternate/aux-server redirection, REFRESH under `--mobility`,
CONNECTION-BIND, and all MI-carrying requests.
- Nonce reuse within its lifetime is how RFC 8489 long-term auth already
works (per-request freshness comes from MESSAGE-INTEGRITY over the
random transaction ID); binding the nonce to the client address is
strictly stronger than a stored random value. The nonce grows from 16 to
24 chars when the flag is on — RFC 8489 requires clients to echo nonces
opaquely up to 128 chars.
- With the flag off, every changed code path short-circuits to stock
behavior.

Design notes, threat model, and limitations: `docs/stateless-nonce.md`.

## Reviewer notes

- The branch is four commits telling the story in order: the feature
with windowed derivation, an int→bool type cleanup around the touched
code, the switch to the timestamp-cookie format (exact expiry, no window
scanning), and the shared fleet key. Happy to squash if preferred.
- `NONCE_MAX_SIZE` grows to hold both nonce formats, and nonce emitters
now use `strlen()` instead of the fixed `NONCE_MAX_SIZE - 1` (which
would otherwise pad the shorter legacy random nonce).
- `stun_is_command_message_full_check_str()` gained `bool` parameters
(was `int`), rippling through its nine in-repo callers.
- One pre-existing test-harness fix rides along:
`run_tests.sh`/`run_tests_mobile.sh` raced clients against a
still-initializing server on macOS (fixed `sleep 2` instead of log
polling); both now poll on both platforms. The CLAUDE.md container
command also gained `libmicrohttpd-dev` and a `git safe.directory` line
it needs on a clean image.
- Known limitation: key rotation for `--stateless-nonce-secret` means
restarting with a new secret (one standard 438 re-auth per client);
sign-with-new/verify-with-both is documented as future work.

## Validation

- New Unity suite `tests/test_stateless_nonce.c`: format, determinism,
per-input divergence, single-character tampering across the whole
cookie, malformed inputs, age/skew boundaries, KDF determinism and
secret binding, and a cross-server test (two keys from one secret
validate each other's nonces; a third secret does not).
- New end-to-end suite `examples/run_tests_stateless_nonce.sh`: standard
relay workload with the feature on over UDP/TCP (+TLS/DTLS and threaded
variants on Linux), asserting the listener fast path actually engaged
and the secret-derived-key startup markers.
- Full battery green on macOS and in a clean Linux container: cmake
build + ctest, `run_tests.sh`, `run_tests_conf.sh`,
`run_tests_mobile.sh`, `run_tests_multiplex_peer.sh`,
`run_tests_rfc5780.sh`, `run_tests_stateless_nonce.sh`, fuzzing smoke
(ASan targets 0/1), and the packaged Docker image bats tests (19 tests,
0 failures).
2026-07-25 16:16:01 -07:00
..