feat(M2): UDP voice/media plane -- SFU relay, Opus, AEAD, jitter buffer

Adds the full voice pipeline: 14-byte binary frame header, ChaCha20-Poly1305
AEAD keyed from the TLS exporter, libopus encode/decode with FEC/PLC/DTX,
an adaptive per-ssrc jitter buffer, a miniaudio capture/playback engine, an
APM passthrough stub, and the UdpBinding/StreamAnnounce signaling chain
wired through ConnSession/SessionRegistry into a new server-side SFU
(MediaRelay) that decrypts and re-encrypts frames per channel member.

Exit criterion verified: test_m2_voice — two headless clients relay 50
encrypted Opus frames through the server; ctest --preset m1-dev is 9/9
green. Also corrects protocol.md's UdpBinding diagram, which described the
UDP-side binding packet as AEAD-sealed when it is in fact a plaintext
bootstrap frame (separate from the TCP/TLS UdpBinding ack).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 01:31:14 +02:00
parent 63f457fc54
commit 694494a5be
29 changed files with 2548 additions and 86 deletions

View File

@@ -128,11 +128,14 @@ Client Server
│ │
│ ◀── ServerStateSnapshot (channel tree, users) ───│ initial sync
│ │
│ UdpBinding(udp_token) [TCP/TLS] ─────────────────▶│ confirms token, no-ops if mismatched
│ ◀── UdpBinding(ack=true) [TCP/TLS] ───────────────│
│ │
│ ===== UDP side (parallel) ===================== │
│ (media keys derived from TLS exporter — no 2nd │
│ handshake; see security.md §2) │
│ UdpBinding(udp_token) [AEAD, exported keys] ────▶│ binds 5-tuple → session_id
── UdpBinding ack [AEAD] ───────────────────────
│ UDP_BINDING frame(udp_token) [plaintext] ───────▶│ binds 5-tuple → session_id
│ ── voice frames (AEAD, exported keys) ──────────
│ │
│ JoinChannelRequest(id, password?) ──────────────▶│
│ ◀── JoinChannelResult(ok, members, audio_cfg) ───│
@@ -151,11 +154,12 @@ Notes:
- **Auth over TLS.** Passwords cross the wire only inside TLS 1.3 and are verified against
an Argon2id hash at rest (see security.md). `auth_methods` in `ServerHello` advertises
whether `guest` is enabled.
- **UDP token.** `AuthResult.udp_token` is a short-lived opaque token. The client sends it
in the first UDP message (`UdpBinding`) so the server can bind the UDP 5-tuple to the
authenticated session without trusting the source address. This is the only UDP message
that carries identity material; everything after is implicit via the bound tuple +
media-AEAD session.
- **UDP token.** `AuthResult.udp_token` is a short-lived opaque token. The client confirms it
over TCP/TLS (`UdpBinding` request/ack) and also sends it as the payload of a plaintext
`UDP_BINDING`-type media frame so the server can bind the UDP 5-tuple to the authenticated
session without trusting the source address. This bootstrap frame is the only UDP message
that carries identity material in the clear; everything after (voice frames) is AEAD-sealed
and routed purely by the bound tuple + media-AEAD session.
- **Snapshot then deltas.** After auth the server pushes a `ServerStateSnapshot` (full
channel tree + visible users), then streams incremental `ChannelEvent`/`UserEvent`
deltas. Clients reconcile by id.